Ship It — From Localhost to Production.
AI, Observability & Three Capstones.
Six parts. Hundreds of concepts. One final chapter where everything comes together. Learn how senior engineers work with AI, observe their systems, deploy safely, and ship with confidence — then build three complete production-grade projects from scratch. This is where you stop learning JavaScript and start operating it.
00 · Introduction — The Last Mile
Six months after the incident. Nadia sat in a coffee shop, laptop open, watching her CI pipeline turn green. Three hundred and forty-seven tests. 92% branch coverage. TypeScript strict mode. Zero ESLint warnings. The deploy was going out on a Friday afternoon — something she'd never have done six months ago.
The new junior engineer sat next to her. "How are you so calm about Friday deploys?" she asked.
Nadia smiled. "Because I finally understand the last mile. Writing code is the easy part. The hard part — the part that separates a script from a product — is everything that happens after the code is written."
This final part is about that last mile. The tools, patterns, and habits that turn code into systems that run reliably in production.
You've come a long way. You understand the event loop, async patterns, the object model, modern syntax, the browser runtime, and how to test and type your code. In this final part, we cover the last 10% that takes 90% of the effort — the production concerns that turn a working program into a reliable product.
What You'll Master in Part 7
AI-Assisted Development
How to pair with Claude, Copilot, and Cursor to 5× your productivity — without losing control of your code.
Observability
Structured logging with Pino, metrics with Prometheus, distributed tracing with OpenTelemetry.
Performance Profiling
Finding the real bottlenecks in production with flame graphs, event loop lag, and memory analysis.
Deployment Strategies
Blue-green, canary, feature flags — the safe ways to ship to millions of users.
Security
Helmet, CORS, rate limiting, secrets management, and how to think like an attacker.
Three Capstone Projects
URL Shortener API. Real-Time Collaborative Tasks. AI Content Moderation. All complete.
The Series Finale
When you finish this article, you'll have gone from "backend developer who panics about JavaScript" to "backend developer who ships production JavaScript with confidence". That's not a small transformation. It's the whole point of this series.
01 · AI-Assisted Development
Nadia's first day with Claude. She pasted a 200-line service file and typed: "Review this. Find bugs, suggest improvements, and rewrite the parts that are hard to test."
Sixty seconds later, Claude identified four issues — including a subtle one she'd missed: an async call inside a loop that should have been batched. It wasn't a bug yet, but it would have been a p99 latency problem under load.
"This feels like cheating," she told Anwar.
"It's not cheating. It's compounding. You still did the thinking. The AI did the remembering, the pattern-matching, the second-pair-of-eyes. The review is still yours."
The Three AI Tools You Need to Know
You don't need all four. Most engineers settle on one primary tool and one secondary. The skill that matters isn't which tool you use — it's how you use it.
The Five Prompt Patterns That Change Everything
// 1. THE REVIEW PATTERN
// Instead of "write this code", ask for critique:
"Review this function as if you were a staff engineer at Stripe.
Identify: correctness bugs, race conditions, performance issues,
missing edge cases, and security concerns. Rank by severity."
// 2. THE TEACH PATTERN
// Understand before accepting:
"Explain what this code does, line by line. Where might it fail?
What assumptions does it make about its inputs?"
// 3. THE REFACTOR PATTERN
// Structure improvements, not just fixes:
"Refactor this to separate the I/O from the business logic.
The result should be testable without any mocks.
Show the before and after."
// 4. THE TEST PATTERN
// Comprehensive test coverage:
"Write Vitest tests for this function. Cover: happy path,
empty input, null/undefined, boundary values, each error path,
async rejection, and one regression test for the bug mentioned
in the comment. Use AAA pattern."
// 5. THE SCENARIO PATTERN
// Explore trade-offs before deciding:
"I'm building a rate limiter for a public API. Options:
in-memory Map, Redis, sliding window vs fixed window.
For 100k requests/sec across 5 servers, which fits best?
Show the trade-offs and code for the winner."
The Golden Rule of AI-Assisted Development
Where AI Excels (and Where It Fails)
✅ AI is amazing at
- Boilerplate generation — configs, DTOs, repetitive patterns.
- Test scaffolding — 80% of a test suite in seconds.
- Explaining unfamiliar code — library internals, legacy code.
- Refactoring across many files — CJS→ESM, callback→async/await.
- Reviewing your own code — catching what you overlooked.
- Translating between patterns — Express → Fastify, Jest → Vitest.
- Documentation and JSDoc comments.
- Brainstorming approaches to a new problem.
❌ AI struggles with
- Business logic that depends on your domain knowledge.
- Anything requiring current library versions or APIs.
- Architectural decisions with trade-offs specific to your team.
- Performance tuning in your specific production environment.
- Security-critical code (always review AI-generated auth).
- Complex state machines with non-obvious invariants.
- Integrating with proprietary internal systems.
- Understanding "why" behind legacy decisions.
Interactive: The AI Pairing Workflow
The discipline that separates AI-powered engineers from AI-replaced ones: never merge code you don't understand. If AI generates something and you can't explain every line to a colleague, you don't own that code — the AI does. When it breaks at 2 AM (and it will), you're the one who fixes it.
02 · Observability — Logs, Metrics, Traces
Three weeks after shipping the fix. Nadia got an alert: p99 latency on the payments endpoint had jumped from 220ms to 1,400ms. But CPU was fine. Memory was fine. No errors in the logs.
Without observability, she'd be guessing. With it, she opened her tracing dashboard and saw the problem in 30 seconds: a downstream service was returning 200 OK but taking 800ms to respond. Every payment call was waiting on it.
She hadn't needed to add any code — the tracing was already there. That's the point of observability. You don't add it during incidents. You add it so incidents become obvious.
The Three Pillars
| Pillar | Answers | Tools |
|---|---|---|
| Logs | "What happened?" — discrete events with context | Pino, Winston, Bunyan, stdout → Loki / Datadog / CloudWatch |
| Metrics | "How is the system behaving?" — numeric time series | prom-client, StatsD → Prometheus → Grafana |
| Traces | "Where is time going?" — request flow across services | OpenTelemetry → Jaeger / Tempo / Datadog APM |
Structured Logging with Pino
console.log('user 42 logged in') is not logging. It's noise. Real logging
produces structured events with typed fields, that tools can query, filter,
and aggregate.
import pino from 'pino';
// Development: pretty-printed, colorized
// Production: JSON to stdout, consumed by log aggregator
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
formatters: {
level: (label) => ({ level: label })
},
base: {
service: 'payment-service',
version: process.env.APP_VERSION,
env: process.env.NODE_ENV
},
timestamp: pino.stdTimeFunctions.isoTime,
redact: {
// Never log these fields — they leak secrets and PII
paths: [
'req.headers.authorization',
'req.headers.cookie',
'req.body.password',
'req.body.cardNumber',
'*.password',
'*.apiKey',
'*.token'
],
remove: true
}
});
// ❌ BAD — plain text, no structure, impossible to query
console.log(`User ${userId} logged in from ${ip}`);
// ✅ GOOD — structured, queryable, aggregatable
logger.info({
userId,
ip,
userAgent: req.headers['user-agent'],
event: 'user.login.success'
}, 'User logged in');
// Now in Grafana you can query:
// count by (event) | event = "user.login.success" | userId = "42"
// Or alert on: anomaly in login rate for a specific user
Request-Scoped Logging with Request IDs
The single most useful logging pattern: every request gets an ID. Every log line inside that request carries the ID. Now you can reconstruct the entire journey of one request.
import { randomUUID } from 'node:crypto';
import { AsyncLocalStorage } from 'node:async_hooks';
const requestContext = new AsyncLocalStorage();
// Middleware: create context for each request
export function requestContextMiddleware(req, res, next) {
const requestId = req.headers['x-request-id'] ?? randomUUID();
const childLogger = logger.child({ requestId });
res.setHeader('x-request-id', requestId);
requestContext.run({ requestId, logger: childLogger }, () => {
const start = Date.now();
res.on('finish', () => {
childLogger.info({
method: req.method,
path: req.path,
status: res.statusCode,
durationMs: Date.now() - start
}, 'request completed');
});
next();
});
}
// Helper — get the current request logger anywhere
export function getLogger() {
return requestContext.getStore()?.logger ?? logger;
}
// Usage in a service (no need to pass logger around)
import { getLogger } from './request-logger.js';
export async function createCharge(data) {
const log = getLogger();
log.info({ customerId: data.customerId, amountCents: data.amountCents }, 'creating charge');
try {
const charge = await bank.charge(data);
log.info({ chargeId: charge.id, status: charge.status }, 'charge created');
return charge;
} catch (err) {
log.error({ err, customerId: data.customerId }, 'charge failed');
throw err;
}
}
Why this pattern is worth the setup: one log query —
{ requestId: "abc-123" } — reconstructs the entire lifecycle of a request
across every function it touched. This turns 30-minute debugging sessions into 30-second
ones.
Metrics with Prometheus
Logs tell you what happened. Metrics tell you how much and how often. They're cheap to store and powerful to alert on.
import client from 'prom-client';
// 1. Enable default metrics (CPU, memory, event loop lag, GC, etc.)
client.collectDefaultMetrics({ prefix: 'node_' });
// 2. Counter — only goes up (total requests, total errors)
export const httpRequestsTotal = new client.Counter({
name: 'http_requests_total',
help: 'Total HTTP requests',
labelNames: ['method', 'route', 'status']
});
// 3. Histogram — distribution (request duration, payload size)
export const httpDuration = new client.Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration in seconds',
labelNames: ['method', 'route', 'status'],
// Buckets chosen so p50, p95, p99 land in distinct buckets
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
});
// 4. Gauge — goes up and down (active connections, queue depth)
export const activeConnections = new client.Gauge({
name: 'active_db_connections',
help: 'Active database connections'
});
// Express middleware — measure every request
export function metricsMiddleware(req, res, next) {
const end = httpDuration.startTimer({
method: req.method,
route: req.route?.path ?? req.path
});
res.on('finish', () => {
const labels = {
method: req.method,
route: req.route?.path ?? req.path,
status: res.statusCode
};
httpRequestsTotal.inc(labels);
end({ ...labels });
});
next();
}
// Endpoint for Prometheus to scrape
app.get('/metrics', async (req, res) => {
res.set('Content-Type', client.register.contentType);
res.end(await client.register.metrics());
});
Once Prometheus scrapes this endpoint, you can query things like:
# Requests per second, by route
rate(http_requests_total[1m])
# p95 latency per route
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))
# Error rate (5xx / total)
sum(rate(http_requests_total{status=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m]))
# Alert: error rate > 1% for 5 minutes
sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) > 0.01
Tracing with OpenTelemetry
Traces show you the full journey of a request through your system — including across microservices, databases, and external APIs. When a request takes 800ms, a trace tells you exactly where those milliseconds went.
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
// Auto-instrumentation: catches Express, pg, redis, http, etc.
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4318/v1/traces'
}),
instrumentations: [getNodeAutoInstrumentations()],
serviceName: 'payment-service'
});
sdk.start();
// Manual spans for custom business logic
import { trace } from '@opentelemetry/api';
const tracer = trace.getTracer('payment-service');
export async function processRefund(orderId, amountCents) {
return tracer.startActiveSpan('processRefund', async (span) => {
span.setAttributes({
'order.id': orderId,
'refund.amount_cents': amountCents
});
try {
const refund = await bank.refund({ orderId, amountCents });
span.setStatus({ code: 1 }); // OK
return refund;
} catch (err) {
span.recordException(err);
span.setStatus({ code: 2, message: err.message }); // ERROR
throw err;
} finally {
span.end();
}
});
}
Interactive: The Observability Dashboard
03 · Production Performance Profiling
Week 4. The tracing dashboard showed the bottleneck wasn't the database. It was the JSON serialization on the response. A payload that should have been 5 KB was 200 KB — because someone had accidentally included a full audit log in every response.
"You would have never found that by reading code," Anwar said. "You find it with profiling. The code looked fine. The actual behavior didn't match."
The Node.js Performance Toolkit
Flame Graphs
Visualize where CPU time is spent. Node's --prof + speedscope.
Event Loop Lag
The single most important Node.js metric. Tracks how backed-up the loop is.
Heap Snapshots
Capture memory state, compare over time, find leaks.
Async Hooks
Trace async operations. Available natively or via async_hooks.
Clinic.js
Suite of tools for CPU, memory, and I/O analysis. Doctor, Flame, Bubbleprof.
Autocannon / k6
Load testing tools — verify performance under realistic traffic.
Event Loop Lag — The Vital Sign
import { monitorEventLoopDelay } from 'node:perf_hooks';
// Track event loop lag with 20ms resolution
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
// Log the stats every 30 seconds
setInterval(() => {
const p50 = histogram.percentile(50) / 1_000_000;
const p95 = histogram.percentile(95) / 1_000_000;
const p99 = histogram.percentile(99) / 1_000_000;
const max = histogram.max / 1_000_000;
logger.info({ p50, p95, p99, max }, 'event loop lag');
// 🚨 Alert if p99 lag > 100ms — the loop is struggling
if (p99 > 100) {
logger.warn({ p99 }, 'HIGH EVENT LOOP LAG — investigate');
}
histogram.reset();
}, 30_000);
// What "lag" means:
// - 0-10ms: perfectly healthy
// - 10-50ms: acceptable, slight delay
// - 50-100ms: concern — user-visible latency
// - >100ms: serious — long blocking operations
// - >1s: emergency — likely synchronous CPU-bound work
The Flame Graph Workflow
Flame graphs show where CPU time went. They're the fastest way to find "what's making my service slow".
# 1. Run Node with CPU profiling enabled
node --cpu-prof --cpu-prof-dir=./profiles server.js
# 2. Generate load (in another terminal)
npx autocannon -c 100 -d 30 http://localhost:3000/api/users
# 3. Stop the server (Ctrl+C)
# → A .cpuprofile file appears in ./profiles/
# 4. Open in Speedscope (web-based flame graph viewer)
# Visit https://speedscope.app and drag the .cpuprofile
# Or use the CLI:
npx speedscope ./profiles/CPU*.cpuprofile
# What you'll see:
# - Wide bars at the top = biggest CPU consumers
# - Look for your own code, not Node internals
# - Common culprits: JSON.parse/stringify, regex, sorting, string concat
Real Profiling Story — The 200 KB Response
// ❌ BEFORE: sends everything, including auditLog and internalMetadata
app.get('/api/orders/:id', async (req, res) => {
const order = await orderRepo.findById(req.params.id, {
include: ['items', 'customer', 'auditLog', 'internalMetadata']
});
res.json(order); // 200 KB response for a 5 KB order
});
// ✅ AFTER: explicit serialization, only what the client needs
function toOrderResponse(order) {
return {
id: order.id,
status: order.status,
total: order.total,
createdAt: order.createdAt,
items: order.items.map((i) => ({
sku: i.sku,
name: i.name,
quantity: i.quantity,
price: i.price
}))
// Intentionally NOT including: auditLog, internalMetadata, cost, margin
};
}
app.get('/api/orders/:id', async (req, res) => {
const order = await orderRepo.findById(req.params.id, {
include: ['items'] // Only load what we serialize
});
if (!order) return res.status(404).json({ error: 'not found' });
res.json(toOrderResponse(order)); // Now 5 KB
});
// Result:
// - Response size: 200 KB → 5 KB (40× reduction)
// - CPU on serialization: 40ms → 2ms
// - p99 latency: 1400ms → 240ms
// - All from 15 lines of "boring" code
The most common production performance wins:
1. Explicit response DTOs — never res.json(entity) directly.
2. Database query optimization — N+1 queries are silent killers.
3. Payload size — often the culprit hiding in plain sight.
4. Caching — the fastest code is code that doesn't run.
5. Streaming large responses — res.write() instead of building big arrays.
04 · Deployment Strategies
Month 4. Nadia's team was now deploying 8 times a day. Zero downtime. Zero 2 AM pages in the previous 30 days. But it hadn't always been this way.
"Do you remember the Friday afternoon deploys?" Anwar asked.
"We don't do those anymore," Nadia said. "We do safe deploys. Canary, feature flags, automated rollback. The whole team can push at any time without fear."
The Deployment Strategies
| Strategy | How It Works | Rollback Time | Best For |
|---|---|---|---|
| Rolling | Replace instances one-by-one | Medium | Default — simple, no extra infra |
| Blue-Green | Two identical environments, switch traffic | Instant | When rollback must be sub-second |
| Canary | Send 1% traffic → 10% → 50% → 100% | Instant | Risky changes, need real-world validation |
| Feature Flags | Deploy dark, enable per user/segment | Instant | User-facing features, A/B tests |
| Shadow | Mirror traffic to new version, compare | N/A (no user impact) | High-risk logic changes |
Interactive: Canary Deployment
Feature Flags — Ship Dark, Enable Later
Feature flags decouple deployment from release. The code is deployed to production, but the feature is disabled. You can enable it gradually, for specific users, or roll it back instantly without redeploying.
// Simple in-memory flags. For production, use LaunchDarkly, Unleash, or PostHog.
const flags = {
'new-checkout': {
enabled: true,
rollout: 0.1, // 10% of users
allowlist: ['user-42', 'user-88'], // always included
denylist: ['user-99'] // always excluded
},
'new-payment-provider': {
enabled: false,
rollout: 0
}
};
function isEnabled(flagName, context) {
const flag = flags[flagName];
if (!flag?.enabled) return false;
if (flag.denylist?.includes(context.userId)) return false;
if (flag.allowlist?.includes(context.userId)) return true;
// Deterministic rollout — same user gets same answer every time
const hash = hashUserId(context.userId, flagName);
return hash < flag.rollout;
}
function hashUserId(userId, flagName) {
// Simple deterministic hash → [0, 1)
const str = `${userId}:${flagName}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash) / 2**31;
}
// Usage in a route
app.post('/checkout', async (req, res) => {
if (isEnabled('new-checkout', { userId: req.user.id })) {
return newCheckoutController.handle(req, res);
}
return legacyCheckoutController.handle(req, res);
});
Feature flag hygiene: flags accumulate. Six months after a feature ships, you'll have 40 flags and nobody remembers which ones still matter. Set a policy: every flag has an owner, a creation date, and a sunset date. Delete flags after the feature is fully rolled out and stable.
Health Checks and Graceful Shutdown
Kubernetes and load balancers need to know two things about your service: is it ready to receive traffic, and is it still alive? Separate endpoints for each.
let isReady = false;
let isShuttingDown = false;
// Liveness — is the process healthy? (If not, kill and restart.)
app.get('/health/live', (req, res) => {
res.status(200).json({ status: 'ok' });
});
// Readiness — can this instance serve traffic? (If not, remove from LB.)
app.get('/health/ready', async (req, res) => {
if (!isReady || isShuttingDown) {
return res.status(503).json({ status: 'not ready' });
}
try {
await db.query('SELECT 1');
await redis.ping();
res.status(200).json({ status: 'ready' });
} catch (err) {
res.status(503).json({ status: 'dependency unavailable', err: err.message });
}
});
// Graceful shutdown — the pattern that prevents dropped requests during deploys
const server = app.listen(3000, () => {
isReady = true;
logger.info('server ready');
});
async function shutdown(signal) {
logger.info({ signal }, 'shutting down');
isShuttingDown = true;
// Stop accepting new requests
server.close(async () => {
// Wait for in-flight requests to finish (with a timeout)
await Promise.race([
waitForInflightRequests(),
new Promise((r) => setTimeout(r, 25_000))
]);
// Close dependencies
await db.destroy();
await redis.quit();
logger.info('shutdown complete');
process.exit(0);
});
// Force exit after 30s
setTimeout(() => {
logger.error('forced shutdown after timeout');
process.exit(1);
}, 30_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
05 · Security for JavaScript Services
Month 5. A researcher reported a vulnerability in one of Nadia's APIs. A user could access another user's data by changing an ID in the URL. Classic IDOR (Insecure Direct Object Reference).
"The code was clean," she said, "but it didn't check ownership."
Anwar nodded. "Security isn't one feature. It's a habit — of assuming every input is hostile, every user could be an attacker, and every check must be explicit."
The Security Checklist for Every Node.js Service
Helmet
HTTP security headers out of the box. Prevents clickjacking, XSS, MIME confusion.
Rate Limiting
Prevent brute force and abuse. Sliding window in Redis for multi-server setups.
Input Validation
Zod or Joi at every boundary. Trust nothing from the outside world.
Auth & AuthZ
JWT or sessions. Always check resource ownership on every read and write.
Secrets Management
Never commit secrets. Use environment variables + a vault (AWS Secrets Manager, Vault).
CORS
Explicit allowlist. Never origin: '*' for authenticated APIs.
Dependency Audit
npm audit in CI. Dependabot/Renovate for automated updates.
Error Sanitization
Never leak stack traces or internal details to clients in production.
The Security Middleware Stack
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
// 1. HTTP security headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"]
}
},
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' }
}));
// 2. CORS — explicit allowlist, never wildcard for APIs with cookies
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com',
...(process.env.NODE_ENV === 'development' ? ['http://localhost:3000'] : [])
];
app.use(cors({
origin: (origin, cb) => {
if (!origin) return cb(null, true); // same-origin / server-to-server
if (allowedOrigins.includes(origin)) return cb(null, true);
cb(new Error('Not allowed by CORS'));
},
credentials: true,
maxAge: 86400
}));
// 3. Global rate limit (per IP)
const globalLimiter = rateLimit({
windowMs: 60_000,
max: 100, // 100 requests per minute per IP
standardHeaders: true,
legacyHeaders: false,
// Use Redis so limits work across multiple servers
store: new RedisStore({ sendCommand: (...args) => redis.sendCommand(args) }),
message: { error: 'Too many requests, please slow down' }
});
app.use('/api/', globalLimiter);
// 4. Stricter limits for sensitive endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60_000,
max: 5, // only 5 login attempts per 15 min
skipSuccessfulRequests: true, // successful logins don't count
store: new RedisStore({ sendCommand: (...args) => redis.sendCommand(args) })
});
app.use('/auth/login', authLimiter);
app.use('/auth/register', authLimiter);
app.use('/auth/forgot-password', authLimiter);
// 5. Body size limits — prevent DoS via huge payloads
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: false, limit: '100kb' }));
// 6. Prevent HTTP parameter pollution
app.use((req, res, next) => {
// Reject query params that are arrays when expecting strings
for (const key in req.query) {
if (Array.isArray(req.query[key])) {
return res.status(400).json({ error: `Duplicate query parameter: ${key}` });
}
}
next();
});
Authentication & the IDOR Bug
Nadia's bug: users could access other users' orders. The code authenticated (verified who you are) but didn't authorize (verified you should have access).
// ❌ VULNERABLE — checks authentication but not authorization
app.get('/api/orders/:id', requireAuth, async (req, res) => {
const order = await orderRepo.findById(req.params.id);
if (!order) return res.status(404).json({ error: 'not found' });
res.json(order); // 💥 anyone can read any order
});
// ✅ FIXED — authorization at the data layer
app.get('/api/orders/:id', requireAuth, async (req, res) => {
// Query scoped to the authenticated user — enforced by the data layer
const order = await orderRepo.findByIdForUser(req.params.id, req.user.id);
if (!order) {
// Return 404 (not 403) — don't reveal whether the resource exists
return res.status(404).json({ error: 'not found' });
}
res.json(toOrderResponse(order));
});
// In the repository — authorization is a WHERE clause, not an if statement
async findByIdForUser(orderId, userId) {
return this.db.queryOne(
'SELECT * FROM orders WHERE id = $1 AND user_id = $2',
[orderId, userId]
);
// ✓ SQL enforces ownership — impossible to leak another user's order
}
The IDOR pattern is the #1 API security bug. Every read and write
endpoint that takes an ID must verify that the authenticated user owns the resource — or
has explicit permission. The cleanest way: enforce it in the query, not in a separate
if statement that someone might forget.
06 · 🏗️ Capstone 1: URL Shortener API
Day 1 of capstone week. Anwar gave the team three projects. "Start with the URL shortener," he said. "It sounds simple, but it teaches you every fundamental of API design: persistence, caching, analytics, rate limiting, and testing. Get this right and you understand ninety percent of what production APIs are."
URL Shortener API
Build a production-ready URL shortener with custom slugs, click analytics, expiry, rate limiting, Redis caching, and comprehensive tests.
What You'll Build
Project Structure
url-shortener/
├── src/
│ ├── server.js # entry point
│ ├── app.js # Express app (testable, no listen)
│ ├── config/
│ │ └── index.js # env parsing + validation
│ ├── db/
│ │ ├── pool.js # pg connection pool
│ │ └── migrations/
│ │ └── 001_init.sql
│ ├── repositories/
│ │ └── url-repository.js # data access
│ ├── services/
│ │ ├── shortener-service.js # business logic
│ │ └── analytics-service.js
│ ├── routes/
│ │ ├── urls.js
│ │ └── health.js
│ ├── middleware/
│ │ ├── auth.js
│ │ ├── rate-limit.js
│ │ ├── error-handler.js
│ │ └── request-context.js
│ ├── schemas/
│ │ └── url-schemas.js # Zod schemas
│ └── lib/
│ ├── logger.js
│ ├── metrics.js
│ └── slug.js # base62 encoding
├── tests/
│ ├── unit/
│ └── integration/
├── Dockerfile
├── docker-compose.yml
├── package.json
└── .env.example
The Slug Generator — Base62
const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const BASE = ALPHABET.length; // 62
/**
* Convert a numeric ID to a short base62 string.
*
* Why base62?
* - URL-safe (no special characters)
* - Case-sensitive (more combinations per character)
* - Compact (7 chars = 62^7 = 3.5 trillion unique slugs)
*
* @param {number} num - Numeric ID from the database
* @returns {string} Base62-encoded string
*/
export function encodeBase62(num) {
if (num === 0) return ALPHABET[0];
let result = '';
while (num > 0) {
result = ALPHABET[num % BASE] + result;
num = Math.floor(num / BASE);
}
return result;
}
export function decodeBase62(str) {
let num = 0;
for (const char of str) {
num = num * BASE + ALPHABET.indexOf(char);
}
return num;
}
// Test
// encodeBase62(1) → '1'
// encodeBase62(62) → '10'
// encodeBase62(3844) → '100'
// encodeBase62(123456) → 'W7E'
The Core Service
import { encodeBase62 } from '../lib/slug.js';
import { getLogger } from '../lib/logger.js';
const RESERVED_SLUGS = new Set([
'api', 'admin', 'health', 'metrics', 'login', 'logout',
'register', 'signup', 'pricing', 'about', 'contact', 'terms', 'privacy'
]);
export class ShortenerService {
#repo;
#cache;
#baseUrl;
constructor({ urlRepository, cache, baseUrl }) {
this.#repo = urlRepository;
this.#cache = cache;
this.#baseUrl = baseUrl;
}
async createShortUrl({ longUrl, customSlug, expiresAt, userId, metadata }) {
const log = getLogger();
// Validate custom slug
if (customSlug) {
if (RESERVED_SLUGS.has(customSlug.toLowerCase())) {
throw new SlugReservedError(customSlug);
}
const existing = await this.#repo.findBySlug(customSlug);
if (existing) throw new SlugTakenError(customSlug);
}
// Insert with a temporary placeholder, then update with base62 slug
// (This is the classic "two-phase slug generation" pattern)
const url = await this.#repo.insert({
longUrl,
slug: customSlug ?? '__pending__',
expiresAt,
userId,
metadata
});
if (!customSlug) {
const slug = encodeBase62(url.id);
await this.#repo.updateSlug(url.id, slug);
url.slug = slug;
}
log.info({ urlId: url.id, slug: url.slug, userId }, 'short url created');
return {
id: url.id,
shortUrl: `${this.#baseUrl}/${url.slug}`,
longUrl: url.longUrl,
slug: url.slug,
expiresAt: url.expiresAt,
createdAt: url.createdAt
};
}
async resolveUrl(slug, { ip, userAgent, referrer } = {}) {
const log = getLogger();
// 1. Check cache first (99% of requests hit this)
let cached = await this.#cache.get(`url:${slug}`);
if (cached) {
log.debug({ slug, hit: true }, 'cache hit');
this.#repo.recordClick({ urlId: cached.id, ip, userAgent, referrer })
.catch((err) => log.error({ err, slug }, 'failed to record click'));
return cached;
}
// 2. Cache miss — hit the database
log.debug({ slug, hit: false }, 'cache miss');
const url = await this.#repo.findBySlug(slug);
if (!url) {
throw new NotFoundError('Short URL not found');
}
// 3. Check expiry
if (url.expiresAt && new Date(url.expiresAt) < new Date()) {
throw new GoneError('Short URL has expired');
}
// 4. Populate cache with TTL
const ttl = url.expiresAt
? Math.min(3600, Math.floor((new Date(url.expiresAt) - new Date()) / 1000))
: 3600;
await this.#cache.set(`url:${slug}`, url, ttl);
// 5. Record click asynchronously (don't block the redirect)
this.#repo.recordClick({ urlId: url.id, ip, userAgent, referrer })
.catch((err) => log.error({ err, slug }, 'failed to record click'));
return url;
}
async getStats(slug, userId) {
const url = await this.#repo.findBySlugForUser(slug, userId);
if (!url) throw new NotFoundError('Short URL not found');
const [clicksByDay, topReferrers, topCountries] = await Promise.all([
this.#repo.clicksByDay(url.id, 30),
this.#repo.topReferrers(url.id, 10),
this.#repo.topCountries(url.id, 10)
]);
return {
slug: url.slug,
totalClicks: url.clickCount,
clicksByDay,
topReferrers,
topCountries,
createdAt: url.createdAt,
expiresAt: url.expiresAt
};
}
}
The Database Schema
CREATE TABLE urls (
id BIGSERIAL PRIMARY KEY,
slug VARCHAR(20) UNIQUE NOT NULL,
long_url TEXT NOT NULL,
user_id BIGINT,
expires_at TIMESTAMPTZ,
click_count BIGINT DEFAULT 0,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_urls_slug ON urls(slug);
CREATE INDEX idx_urls_user_id ON urls(user_id) WHERE user_id IS NOT NULL;
CREATE INDEX idx_urls_expires_at ON urls(expires_at) WHERE expires_at IS NOT NULL;
CREATE TABLE clicks (
id BIGSERIAL PRIMARY KEY,
url_id BIGINT REFERENCES urls(id) ON DELETE CASCADE,
ip_address INET,
user_agent TEXT,
referrer TEXT,
country_code CHAR(2),
clicked_at TIMESTAMPTZ DEFAULT NOW()
);
-- Partition by month for scale (millions of rows)
CREATE INDEX idx_clicks_url_id_clicked_at ON clicks(url_id, clicked_at DESC);
-- Materialized view for fast analytics
CREATE MATERIALIZED VIEW url_daily_clicks AS
SELECT
url_id,
DATE_TRUNC('day', clicked_at) AS day,
COUNT(*) AS clicks
FROM clicks
GROUP BY url_id, DATE_TRUNC('day', clicked_at);
CREATE UNIQUE INDEX idx_url_daily_clicks ON url_daily_clicks(url_id, day);
The Express Routes
import { Router } from 'express';
import { z } from 'zod';
import { validate } from '../middleware/validate.js';
import { requireAuth } from '../middleware/auth.js';
import { asyncHandler } from '../middleware/async-handler.js';
const CreateUrlSchema = z.object({
longUrl: z.string().url().max(2048),
customSlug: z.string().regex(/^[a-zA-Z0-9_-]{3,20}$/).optional(),
expiresInDays: z.number().int().min(1).max(3650).optional(),
metadata: z.record(z.string(), z.unknown()).optional()
});
export function createUrlRoutes({ shortenerService }) {
const router = Router();
// POST /api/urls — create a short URL
router.post('/',
requireAuth,
validate(CreateUrlSchema),
asyncHandler(async (req, res) => {
const { longUrl, customSlug, expiresInDays, metadata } = req.body;
const expiresAt = expiresInDays
? new Date(Date.now() + expiresInDays * 86_400_000)
: null;
const result = await shortenerService.createShortUrl({
longUrl,
customSlug,
expiresAt,
userId: req.user.id,
metadata
});
res.status(201).json(result);
})
);
// GET /api/urls/:slug/stats
router.get('/:slug/stats',
requireAuth,
asyncHandler(async (req, res) => {
const stats = await shortenerService.getStats(req.params.slug, req.user.id);
res.json(stats);
})
);
// DELETE /api/urls/:slug
router.delete('/:slug',
requireAuth,
asyncHandler(async (req, res) => {
await shortenerService.deleteUrl(req.params.slug, req.user.id);
res.status(204).end();
})
);
return router;
}
// The public redirect route
export function createRedirectRoute({ shortenerService }) {
const router = Router();
router.get('/:slug',
asyncHandler(async (req, res) => {
const url = await shortenerService.resolveUrl(req.params.slug, {
ip: req.ip,
userAgent: req.headers['user-agent'],
referrer: req.headers['referer']
});
res.redirect(302, url.longUrl);
})
);
return router;
}
The Tests
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import request from 'supertest';
import { createApp } from '../../src/app.js';
import { createTestDb } from '../helpers/test-db.js';
describe('URL shortener API', () => {
let app, db, token;
beforeAll(async () => {
db = await createTestDb();
app = createApp({ db });
token = await createTestUser(db); // returns JWT
});
afterAll(async () => await db.destroy());
beforeEach(async () => {
await db.query('TRUNCATE urls, clicks RESTART IDENTITY CASCADE');
});
describe('POST /api/urls', () => {
it('creates a short URL with a generated slug', async () => {
const res = await request(app)
.post('/api/urls')
.set('Authorization', `Bearer ${token}`)
.send({ longUrl: 'https://example.com/very/long/path' });
expect(res.status).toBe(201);
expect(res.body).toMatchObject({
slug: expect.any(String),
shortUrl: expect.stringContaining(res.body.slug),
longUrl: 'https://example.com/very/long/path'
});
});
it('rejects invalid URLs', async () => {
const res = await request(app)
.post('/api/urls')
.set('Authorization', `Bearer ${token}`)
.send({ longUrl: 'not-a-url' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('url');
});
it('rejects reserved custom slugs', async () => {
const res = await request(app)
.post('/api/urls')
.set('Authorization', `Bearer ${token}`)
.send({ longUrl: 'https://example.com', customSlug: 'admin' });
expect(res.status).toBe(422);
});
it('rejects duplicate custom slugs', async () => {
await request(app)
.post('/api/urls')
.set('Authorization', `Bearer ${token}`)
.send({ longUrl: 'https://a.com', customSlug: 'mylink' });
const res = await request(app)
.post('/api/urls')
.set('Authorization', `Bearer ${token}`)
.send({ longUrl: 'https://b.com', customSlug: 'mylink' });
expect(res.status).toBe(409);
});
});
describe('GET /:slug (redirect)', () => {
it('redirects to the long URL', async () => {
const createRes = await request(app)
.post('/api/urls')
.set('Authorization', `Bearer ${token}`)
.send({ longUrl: 'https://example.com/target', customSlug: 'test1' });
const res = await request(app).get('/test1').redirects(0);
expect(res.status).toBe(302);
expect(res.headers.location).toBe('https://example.com/target');
});
it('returns 404 for unknown slugs', async () => {
const res = await request(app).get('/nonexistent');
expect(res.status).toBe(404);
});
it('returns 410 for expired URLs', async () => {
await request(app)
.post('/api/urls')
.set('Authorization', `Bearer ${token}`)
.send({ longUrl: 'https://a.com', customSlug: 'expired', expiresInDays: 1 });
// Manually expire it
await db.query("UPDATE urls SET expires_at = NOW() - INTERVAL '1 day' WHERE slug = 'expired'");
const res = await request(app).get('/expired');
expect(res.status).toBe(410);
});
});
describe('GET /api/urls/:slug/stats', () => {
it('returns click statistics for the owner', async () => {
const create = await request(app)
.post('/api/urls')
.set('Authorization', `Bearer ${token}`)
.send({ longUrl: 'https://a.com', customSlug: 'stats1' });
// Simulate 3 clicks
for (let i = 0; i < 3; i++) {
await request(app).get('/stats1');
}
// Wait for async click recording
await new Promise((r) => setTimeout(r, 100));
const res = await request(app)
.get('/api/urls/stats1/stats')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.totalClicks).toBe(3);
});
it('returns 404 for another user\'s URL (IDOR prevention)', async () => {
// User A creates a URL
await request(app)
.post('/api/urls')
.set('Authorization', `Bearer ${token}`)
.send({ longUrl: 'https://a.com', customSlug: 'secret' });
// User B tries to access it
const otherToken = await createTestUser(db, 'other@example.com');
const res = await request(app)
.get('/api/urls/secret/stats')
.set('Authorization', `Bearer ${otherToken}`);
expect(res.status).toBe(404);
});
});
});
Docker Compose — Run Everything Locally
version: '3.9'
services:
api:
build: .
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_URL: postgres://urlshortener:secret@postgres:5432/urlshortener
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET}
BASE_URL: http://localhost:3000
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: urlshortener
POSTGRES_PASSWORD: secret
POSTGRES_DB: urlshortener
volumes:
- pgdata:/var/lib/postgresql/data
- ./src/db/migrations:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U urlshortener"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:
redisdata:
What you've learned building this:
✓ Express structure that's testable without spawning a server.
✓ Base62 encoding — a real algorithm used by bit.ly and TinyURL.
✓ Two-phase slug generation (insert → encode → update).
✓ Cache-aside pattern with Redis and TTL calculated from expiry.
✓ Async click recording (fire-and-forget with error logging).
✓ IDOR prevention via query-level authorization.
✓ Reserved slug protection.
✓ Rate limiting scoped per endpoint.
✓ Zod validation at the boundary.
✓ Integration tests with real HTTP, real DB, and IDOR regression tests.
07 · 🏗️ Capstone 2: Real-Time Collaborative Task Manager
Day 3 of capstone week. "Now the hard one," Anwar said. "Real-time. Multiple users editing the same data at the same time. Presence indicators. Conflict resolution. This is where you learn that synchronous request/response is a special case, not the rule."
Real-Time Collaborative Task Manager
Multi-user boards with live updates, presence indicators, optimistic UI, offline resilience, and conflict resolution using Lamport-style clocks.
Architecture
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Browser A │────│ WebSocket │────│ Postgres │
│ (Client) │ │ Server │ │ │
└─────────────┘ │ (Socket.io) │ │ - tasks │
│ │ │ - boards │
┌─────────────┐ │ ┌─────────┐ │ │ - users │
│ Browser B │────│ │ Redis │ │ │ - audit_log │
│ (Client) │ │ │ Pub/Sub │ │ └──────────────┘
└─────────────┘ │ └─────────┘ │
│ │ ┌──────────────┐
┌─────────────┐ │ ┌─────────┐ │────│ REST API │
│ Browser C │────│ │Room Mgr │ │ │ (Express) │
│ (Client) │ │ └─────────┘ │ └──────────────┘
└─────────────┘ └──────────────┘
│ │
└─────────── JWT Auth ──────────────────┘
WebSocket Server with Socket.io
import { Server as SocketIOServer } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
import { verifyToken } from '../lib/jwt.js';
import { getLogger } from '../lib/logger.js';
export async function createWsServer(httpServer, { taskService, presenceService, boardService }) {
const io = new SocketIOServer(httpServer, {
cors: { origin: process.env.FRONTEND_URL, credentials: true },
// Long-polling fallback + upgrade to WS
transports: ['websocket', 'polling'],
// Heartbeat
pingInterval: 25_000,
pingTimeout: 60_000,
maxHttpBufferSize: 1e6 // 1 MB max message
});
// Redis adapter — allows multi-instance WebSocket servers
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
// Authentication — verify JWT on connection
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth.token;
if (!token) return next(new Error('Authentication required'));
const user = await verifyToken(token);
socket.data.user = user;
socket.data.sessionId = crypto.randomUUID();
next();
} catch (err) {
next(new Error('Invalid token'));
}
});
io.on('connection', (socket) => {
const log = getLogger();
const user = socket.data.user;
log.info({ userId: user.id, socketId: socket.id }, 'ws connected');
// ─────────────────────────────────────────────────
// Board room management
// ─────────────────────────────────────────────────
socket.on('board:join', async ({ boardId }, cb) => {
try {
// Authorization — verify user has access to this board
const hasAccess = await boardService.userHasAccess(boardId, user.id);
if (!hasAccess) return cb({ error: 'Access denied' });
const room = `board:${boardId}`;
socket.join(room);
socket.data.boardId = boardId;
// Track presence
await presenceService.addUser(boardId, {
userId: user.id,
sessionId: socket.data.sessionId,
name: user.name,
avatarUrl: user.avatarUrl,
joinedAt: new Date().toISOString()
});
// Broadcast to room that someone joined
socket.to(room).emit('presence:joined', {
userId: user.id,
name: user.name,
avatarUrl: user.avatarUrl,
sessionId: socket.data.sessionId
});
// Send current state to the joining user
const [tasks, presence] = await Promise.all([
taskService.listForBoard(boardId),
presenceService.listForBoard(boardId)
]);
cb({ tasks, presence });
} catch (err) {
log.error({ err, boardId }, 'board:join failed');
cb({ error: 'Failed to join board' });
}
});
// ─────────────────────────────────────────────────
// Task mutations with optimistic concurrency
// ─────────────────────────────────────────────────
socket.on('task:update', async ({ taskId, patch, clientTimestamp }, cb) => {
const boardId = socket.data.boardId;
if (!boardId) return cb({ error: 'Not in a board' });
try {
// Use a Lamport clock for ordering events across clients
const lamport = nextLamport(boardId, clientTimestamp);
// Update with version check — prevents lost updates
const result = await taskService.updateWithVersion({
taskId,
patch,
expectedVersion: patch.version,
userId: user.id,
lamport
});
if (result.conflict) {
// Server wins — client must reconcile
return cb({
conflict: true,
current: result.current,
message: 'Task was modified by another user'
});
}
// Broadcast to everyone in the room (including the sender for confirmation)
io.to(`board:${boardId}`).emit('task:updated', {
task: result.task,
by: { userId: user.id, name: user.name },
lamport
});
cb({ ok: true, task: result.task });
} catch (err) {
log.error({ err, taskId }, 'task:update failed');
cb({ error: 'Update failed' });
}
});
socket.'task:create', async ({ title, description, status }, cb) => {
const boardId = socket.data.boardId;
if (!boardId) return cb({ error: 'Not in a board' });
try {
const task = await taskService.create({
boardId,
title,
description,
status: status ?? 'todo',
createdBy: user.id
});
io.to(`board:${boardId}`).emit('task:created', {
task,
by: { userId: user.id, name: user.name }
});
cb({ ok: true, task });
} catch (err) {
cb({ error: err.message });
}
});
// ─────────────────────────────────────────────────
// Typing indicators — ephemeral, not persisted
// ─────────────────────────────────────────────────
socket.on('task:typing', ({ taskId, isTyping }) => {
const boardId = socket.data.boardId;
if (!boardId) return;
socket.to(`board:${boardId}`).emit('task:typing', {
taskId,
userId: user.id,
name: user.name,
isTyping
});
});
// Cursor position — for future collaborative editing
socket.on('cursor:move', ({ x, y }) => {
const boardId = socket.data.boardId;
if (!boardId) return;
socket.to(`board:${boardId}`).emit('cursor:moved', { userId: user.id, x, y });
});
// ─────────────────────────────────────────────────
// Disconnect — clean up presence
// ─────────────────────────────────────────────────
socket.on('disconnect', async (reason) => {
const boardId = socket.data.boardId;
log.info({ userId: user.id, boardId, reason }, 'ws disconnected');
if (boardId) {
await presenceService.removeUser(boardId, socket.data.sessionId);
io.to(`board:${boardId}`).emit('presence:left', {
userId: user.id,
sessionId: socket.data.sessionId
});
}
});
});
return io;
}
// Lamport clock — monotonic across nodes via Redis
async function nextLamport(boardId, clientTs = 0) {
const key = `lamport:${boardId}`;
const serverTime = Date.now();
const next = Math.max(serverTime, clientTs) + 1;
await redis.set(key, next, { EX: 86400 });
return next;
}
Optimistic Concurrency in the Repository
export class TaskRepository {
#db;
constructor({ db }) { this.#db = db; }
async updateWithVersion({ taskId, patch, expectedVersion, userId, lamport }) {
// Atomic compare-and-swap at the SQL level
// This is the ONLY place we need to worry about races — the DB handles it
const result = await this.#db.query(
`
UPDATE tasks
SET
title = COALESCE($1, title),
description = COALESCE($2, description),
status = COALESCE($3, status),
version = version + 1,
updated_at = NOW(),
updated_by = $4,
lamport = $5
WHERE id = $6 AND version = $7
RETURNING *
`,
[patch.title, patch.description, patch.status, userId, lamport, taskId, expectedVersion]
);
if (result.rowCount === 0) {
// Version mismatch — someone else updated first
const current = await this.#db.queryOne('SELECT * FROM tasks WHERE id = $1', [taskId]);
return { conflict: true, current };
}
// Audit log — who changed what, when
await this.#db.query(
`
INSERT INTO task_audit (task_id, user_id, change, lamport, occurred_at)
VALUES ($1, $2, $3, $4, NOW())
`,
[taskId, userId, JSON.stringify(patch), lamport]
);
return { task: result.rows[0] };
}
}
Presence Service with Redis
export class PresenceService {
#redis;
#ttlSeconds = 60; // Sessions expire if client stops heartbeating
constructor({ redis }) { this.#redis = redis; }
async addUser(boardId, userSession) {
const key = `presence:${boardId}`;
await this.#redis.hSet(key, userSession.sessionId, JSON.stringify(userSession));
await this.#redis.expire(key, this.#ttlSeconds * 10);
}
async removeUser(boardId, sessionId) {
await this.#redis.hDel(`presence:${boardId}`, sessionId);
}
async heartbeat(boardId, sessionId) {
// Keep the presence alive
const key = `presence:${boardId}`;
const userData = await this.#redis.hGet(key, sessionId);
if (userData) {
const parsed = JSON.parse(userData);
parsed.lastSeen = new Date().toISOString();
await this.#redis.hSet(key, sessionId, JSON.stringify(parsed));
}
}
async listForBoard(boardId) {
const raw = await this.#redis.hGetAll(`presence:${boardId}`);
return Object.values(raw).map(JSON.parse);
}
}
The Client — Optimistic UI + Reconnection
import { io } from 'socket.io-client';
export class BoardClient {
#socket;
#pendingUpdates = new Map(); // taskId → optimistic version
#listeners = new Map();
connect(token, boardId) {
this.#socket = io('wss://api.example.com', {
auth: { token },
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 30_000
});
this.#socket.on('connect', () => {
this.#socket.emit('board:join', { boardId }, (response) => {
if (response.error) {
this.#emit('error', response.error);
return;
}
this.#emit('snapshot', response);
this.#startHeartbeat(boardId);
});
});
this.#socket.on('task:updated', ({ task, by }) => {
// If this was our optimistic update, confirm it
if (this.#pendingUpdates.has(task.id)) {
this.#pendingUpdates.delete(task.id);
this.#emit('task:confirmed', { task });
} else {
// Someone else updated it
this.#emit('task:remote-update', { task, by });
}
});
this.#socket.on('presence:joined', (user) => this.#emit('presence:joined', user));
this.#socket.on('presence:left', (user) => this.#emit('presence:left', user));
this.#socket.on('task:created', ({ task }) => this.#emit('task:created', { task }));
this.#socket.on('task:typing', (data) => this.#emit('task:typing', data));
}
updateTask(taskId, patch, currentVersion) {
// OPTIMISTIC — apply locally immediately
this.#pendingUpdates.set(taskId, currentVersion);
this.#emit('task:optimistic', { taskId, patch });
// Send to server
this.#socket.emit('task:update', {
taskId,
patch: { ...patch, version: currentVersion },
clientTimestamp: Date.now()
}, (response) => {
if (response.conflict) {
// Server has newer version — roll back optimistic update
this.#pendingUpdates.delete(taskId);
this.#emit('task:conflict', {
taskId,
attempted: patch,
current: response.current,
message: response.message
});
} else if (response.error) {
this.#pendingUpdates.delete(taskId);
this.#emit('task:error', { taskId, error: response.error });
}
});
}
#startHeartbeat(boardId) {
this.#heartbeatId = setInterval(() => {
this.#socket.emit('presence:heartbeat', { boardId });
}, 20_000);
}
#emit(event, data) {
const handlers = this.#listeners.get(event) ?? [];
handlers.forEach((h) => h(data));
}
on(event, handler) {
if (!this.#listeners.has(event)) this.#listeners.set(event, []);
this.#listeners.get(event).push(handler);
return () => {
const handlers = this.#listeners.get(event);
const idx = handlers.indexOf(handler);
if (idx >= 0) handlers.splice(idx, 1);
};
}
disconnect() {
if (this.#heartbeatId) clearInterval(this.#heartbeatId);
this.#socket?.disconnect();
}
}
Interactive: Watch Real-Time Updates Flow
What you've learned building this:
✓ WebSocket server with JWT authentication.
✓ Room-based broadcast architecture.
✓ Redis Pub/Sub for horizontal scaling.
✓ Presence tracking with Redis hashes and TTLs.
✓ Optimistic UI updates with rollback on conflict.
✓ Lamport clocks for cross-client ordering.
✓ Version-based optimistic concurrency (compare-and-swap in SQL).
✓ Audit logging of every change.
✓ Graceful reconnection with exponential backoff.
✓ Heartbeat-based presence detection.
08 · 🏗️ Capstone 3: AI Content Moderation Service
Day 6 of capstone week. "The last one," Anwar said, "and the most modern. You're building a service that receives user content, sends it to an AI model for moderation, and returns a verdict. The challenges: rate limits, costs, latency, streaming responses, and graceful degradation when the AI is slow or down."
"This is what production JavaScript looks like in 2026. Async everywhere. Third-party APIs you don't control. Costs you must manage. And a system that must keep working when any individual piece fails."
AI Content Moderation Service
Queue-based content moderation with Claude API, streaming responses, per-tenant rate limits, cost tracking, circuit breakers, and graceful fallback to heuristic rules.
Architecture
┌──────────────────┐
HTTP Request ──▶│ Express API │
│ (validate + │
│ enqueue) │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Redis Queue │ ◀── Priority-based
│ (BullMQ) │ + retry + backoff
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Worker Pool │ ◀── Concurrent workers
│ (N processes) │ across N containers
└────────┬─────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Claude │ │ Heuristic│ │ Postgres │
│ API │ │ Fallback │ │ (results)│
└──────────┘ └──────────┘ └──────────┘
│
▼
┌──────────────┐
│ Webhook / │
│ Polling API │
└──────────────┘
The API — Enqueue and Return Immediately
import { Router } from 'express';
import { z } from 'zod';
const ModerateSchema = z.object({
content: z.string().min(1).max(10_000),
contentType: z.enum(['text', 'html', 'markdown']).default('text'),
callbackUrl: z.string().url().optional(),
priority: z.enum(['low', 'normal', 'high']).default('normal'),
tenantId: z.string()
});
export function createModerateRoutes({ moderationService }) {
const router = Router();
// Async endpoint — returns job ID immediately
router.post('/jobs',
validate(ModerateSchema),
asyncHandler(async (req, res) => {
const job = await moderationService.enqueue({
...req.body,
tenantId: req.user.tenantId,
submittedBy: req.user.id
});
res.status(202).json({
jobId: job.id,
status: 'queued',
estimatedWaitMs: await moderationService.estimateWait(req.body.priority),
pollUrl: `/api/moderate/jobs/${job.id}`
});
})
);
// Poll for result
router.get('/jobs/:id',
asyncHandler(async (req, res) => {
const result = await moderationService.getJob(req.params.id, req.user.tenantId);
if (!result) return res.status(404).json({ error: 'Job not found' });
res.json(result);
})
);
// Streaming endpoint — real-time moderation for interactive use
router.post('/stream',
validate(ModerateSchema),
async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const controller = new AbortController();
req.on('close', () => controller.abort());
try {
for await (const event of moderationService.stream(req.body, { signal: controller.signal })) {
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`);
}
res.end();
} catch (err) {
res.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`);
res.end();
}
}
);
return router;
}
The Moderation Service — Queue + Circuit Breaker
import { Queue, Worker } from 'bullmq';
import { CircuitBreaker } from '../lib/circuit-breaker.js';
import { getLogger } from '../lib/logger.js';
export class ModerationService {
#queue;
#worker;
#repo;
#aiClient;
#heuristic;
#breaker;
#redis;
constructor({ redis, repo, aiClient, heuristic }) {
this.#redis = redis;
this.#repo = repo;
this.#aiClient = aiClient;
this.#heuristic = heuristic;
// Circuit breaker — trips after 5 failures in 60s, retries after 30s
this.#breaker = new CircuitBreaker({
failureThreshold: 5,
successThreshold: 2,
timeout: 60_000,
resetTimeout: 30_000
});
// BullMQ queue — jobs stored in Redis, workers process them
this.#queue = new Queue('moderation', {
connection: redis,
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: { age: 86400, count: 10_000 },
removeOnFail: { age: 604800 }
}
});
}
async enqueue({ content, contentType, callbackUrl, priority, tenantId, submittedBy }) {
// 1. Enforce per-tenant rate limits BEFORE enqueueing
const allowed = await this.#checkTenantQuota(tenantId);
if (!allowed) throw new QuotaExceededError(tenantId);
// 2. Persist job metadata in Postgres (source of truth)
const job = await this.#repo.createJob({
tenantId,
submittedBy,
contentType,
callbackUrl,
priority,
status: 'queued',
contentHash: sha256(content) // for dedup + cache lookup
});
// 3. Check cache — same content moderated recently?
const cached = await this.#repo.findCachedResult(job.contentHash, 3600);
if (cached) {
await this.#repo.completeJob(job.id, cached.result, { cached: true });
return { ...job, status: 'completed' };
}
// 4. Enqueue to BullMQ with priority
await this.#queue.add(
'moderate',
{ jobId: job.id, content, contentType, tenantId },
{ priority: this.#priorityValue(priority), jobId: job.id }
);
return job;
}
// Streaming moderation — for interactive use
async* stream({ content, contentType }, { signal }) {
yield { type: 'started', data: { contentType, length: content.length } };
// Stage 1: heuristic pre-filter (fast, no AI)
const heuristicResult = this.#heuristic.analyze(content);
yield { type: 'heuristic', data: heuristicResult };
// Early exit — obvious violations don't need AI
if (heuristicResult.severity === 'block') {
yield { type: 'verdict', data: { ...heuristicResult, source: 'heuristic' } };
return;
}
// Stage 2: AI moderation (streaming)
if (this.#breaker.isOpen()) {
yield {
type: 'fallback',
data: { reason: 'AI circuit breaker open' }
};
yield { type: 'verdict', data: { ...heuristicResult, source: 'fallback' } };
return;
}
try {
const stream = await this.#breaker.execute(() =>
this.#aiClient.streamModerate(content, { signal })
);
let fullText = '';
for await (const chunk of stream) {
fullText += chunk.text;
yield { type: 'ai-chunk', data: { text: chunk.text } };
}
const verdict = this.#parseAiVerdict(fullText);
yield { type: 'verdict', data: { ...verdict, source: 'ai' } };
} catch (err) {
yield { type: 'error', data: { message: err.message } };
yield { type: 'verdict', data: { ...heuristicResult, source: 'fallback' } };
}
}
async getJob(jobId, tenantId) {
return this.#repo.findJob(jobId, tenantId);
}
async estimateWait(priority) {
const counts = await this.#queue.getJobCounts();
const base = (counts.waiting ?? 0) * 200; // 200ms avg per job
const multiplier = { high: 0.3, normal: 1, low: 3 }[priority] ?? 1;
return Math.round(base * multiplier);
}
startWorker() {
this.#worker = new Worker('moderation', async (job) => {
const log = getLogger();
const { jobId, content, tenantId } = job.data;
try {
await this.#repo.markProcessing(jobId);
// Try AI with circuit breaker
let verdict;
if (this.#breaker.isOpen()) {
log.warn({ jobId }, 'circuit breaker open, using fallback');
verdict = { ...this.#heuristic.analyze(content), source: 'fallback' };
} else {
const aiResult = await this.#breaker.execute(() =>
this.#aiClient.moderate(content)
);
verdict = { ...aiResult, source: 'ai' };
}
await this.#repo.completeJob(jobId, verdict, { cached: false });
// Fire webhook if provided
const meta = await this.#repo.findJob(jobId, tenantId);
if (meta?.callbackUrl) {
await this.#deliverWebhook(meta.callbackUrl, { jobId, verdict }).catch((err) =>
log.error({ err, jobId }, 'webhook delivery failed')
);
}
return verdict;
} catch (err) {
log.error({ err, jobId }, 'job failed');
await this.#repo.failJob(jobId, err.message);
throw err; // BullMQ will retry
}
}, {
connection: this.#redis,
concurrency: 10,
limiter: {
max: 100, // max 100 jobs per duration across the pool
duration: 60_000
}
});
this.#worker.on('completed', (job) => {
getLogger().info({ jobId: job.id }, 'job completed');
});
this.#worker.on('failed', (job, err) => {
getLogger().error({ jobId: job?.id, err: err.message, attemptsMade: job?.attemptsMade }, 'job failed permanently');
});
}
async #checkTenantQuota(tenantId) {
const key = `quota:${tenantId}:${new Date().toISOString().slice(0, 13)}`; // hourly bucket
const current = await this.#redis.incr(key);
if (current === 1) await this.#redis.expire(key, 7200);
return current <= this.#getTenantLimit(tenantId);
}
#getTenantLimit(tenantId) {
// In production, look this up from a tenant table
return 1000; // 1000 moderation jobs per hour per tenant
}
#priorityValue(priority) {
// Lower number = higher priority in BullMQ
return { high: 1, normal: 5, low: 10 }[priority] ?? 5;
}
async #deliverWebhook(url, payload) {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Signature': hmacSha256(JSON.stringify(payload), process.env.WEBHOOK_SECRET)
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10_000)
});
if (!res.ok) throw new Error(`Webhook returned ${res.status}`);
}
#parseAiVerdict(text) {
// The AI returns structured JSON — parse and validate with Zod
const match = text.match(/\{[\s\S]*\}/);
if (!match) throw new Error('AI response missing JSON');
const parsed = JSON.parse(match[0]);
return VerdictSchema.parse(parsed);
}
}
The Circuit Breaker
/**
* Circuit Breaker — prevents cascading failures when a dependency is down.
*
* Three states:
* CLOSED — everything working, calls pass through
* OPEN — too many failures, calls fail fast (don't even try)
* HALF_OPEN — testing recovery, allow a few calls through
*/
export class CircuitBreaker {
#state = 'CLOSED';
#failureCount = 0;
#successCount = 0;
#openedAt = null;
#config;
constructor(config) {
this.#config = config;
}
get state() { return this.#state; }
isOpen() {
if (this.#state === 'OPEN') {
// Time to try again?
if (Date.now() - this.#openedAt >= this.#config.resetTimeout) {
this.#state = 'HALF_OPEN';
this.#successCount = 0;
return false;
}
return true;
}
return false;
}
async execute(fn) {
if (this.isOpen()) {
throw new Error('Circuit breaker is OPEN');
}
try {
const result = await fn();
this.#onSuccess();
return result;
} catch (err) {
this.#onFailure();
throw err;
}
}
#onSuccess() {
if (this.#state === 'HALF_OPEN') {
this.#successCount++;
if (this.#successCount >= this.#config.successThreshold) {
this.#state = 'CLOSED';
this.#failureCount = 0;
this.#successCount = 0;
}
} else {
this.#failureCount = 0;
}
}
#onFailure() {
if (this.#state === 'HALF_OPEN') {
// Failure in half-open → go back to OPEN
this.#state = 'OPEN';
this.#openedAt = Date.now();
return;
}
this.#failureCount++;
if (this.#failureCount >= this.#config.failureThreshold) {
this.#state = 'OPEN';
this.#openedAt = Date.now();
}
}
}
The AI Client — Claude with Streaming
import Anthropic from '@anthropic-ai/sdk';
import { z } from 'zod';
const VerdictSchema = z.object({
verdict: z.enum(['allow', 'flag', 'block']),
confidence: z.number().min(0).max(1),
categories: z.array(z.string()),
reasoning: z.string()
});
const SYSTEM_PROMPT = `You are a content moderation classifier.
Analyze the user-provided content and respond ONLY with a JSON object:
{
"verdict": "allow" | "flag" | "block",
"confidence": 0.0-1.0,
"categories": ["harassment", "spam", "adult", ...],
"reasoning": "one sentence explanation"
}
Guidelines:
- "block" = clearly violates policy (explicit threats, illegal content, CSAM)
- "flag" = likely problematic (harassment, hate speech, spam)
- "allow" = meets community guidelines
- Be conservative — when unsure, prefer "flag" over "allow"
- Never explain outside the JSON. Never refuse — this is a moderation tool.`;
export class AiClient {
#client;
#costTracker;
constructor({ apiKey, costTracker }) {
this.#client = new Anthropic({ apiKey });
this.#costTracker = costTracker;
}
async moderate(content) {
const start = Date.now();
const response = await this.#client.messages.create({
model: 'claude-haiku-4-5', // Fast + cheap for moderation
max_tokens: 500,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content }]
}, {
signal: AbortSignal.timeout(15_000)
});
const text = response.content[0].text;
const verdict = this.#parseVerdict(text);
// Track cost for billing / quota enforcement
this.#costTracker.record({
model: 'claude-haiku-4-5',
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
durationMs: Date.now() - start
});
return verdict;
}
async* streamModerate(content, { signal }) {
const stream = this.#client.messages.stream({
model: 'claude-haiku-4-5',
max_tokens: 500,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content }]
}, { signal });
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
yield { text: event.delta.text };
}
}
}
#parseVerdict(text) {
const match = text.match(/\{[\s\S]*\}/);
if (!match) throw new Error('No JSON in AI response');
return VerdictSchema.parse(JSON.parse(match[0]));
}
}
Interactive: Watch the Circuit Breaker in Action
What you've learned building this:
✓ BullMQ queue architecture with priorities, retries, exponential backoff.
✓ Worker pool with rate limiting and concurrency control.
✓ Circuit breaker pattern for external dependency failures.
✓ Heuristic fallback when AI is unavailable.
✓ Streaming responses via Server-Sent Events.
✓ Content-hash caching to avoid duplicate moderation costs.
✓ Per-tenant rate limiting with Redis counters.
✓ Webhook delivery with HMAC signatures and timeouts.
✓ Cost tracking for AI usage (essential for business viability).
✓ Structured logging + metrics across all components.
09 · The Complete Developer's Toolkit
After seven parts and three capstones, you have a serious technical foundation. Here's the curated toolkit for continuing to grow — the tools, resources, and habits that senior JavaScript engineers rely on.
Essential Daily Tools
| Category | Tools | Why |
|---|---|---|
| Runtime | Node.js 22 LTS, Bun, Deno | Node is the standard. Bun and Deno are worth exploring for specific use cases. |
| Framework | Express, Fastify, Hono, NestJS | Express for familiarity. Fastify for perf. Hono for edge. NestJS for large teams. |
| Database | Postgres, Drizzle ORM, Prisma | Postgres is the default. Drizzle is type-safe SQL. Prisma for schema-first DX. |
| Cache / Queue | Redis, BullMQ | Redis is the Swiss army knife. BullMQ for background jobs. |
| Validation | Zod, Valibot | Runtime validation + type inference at the boundaries. |
| Testing | Vitest, Playwright, Supertest | Modern standard across all three test layers. |
| Observability | Pino, OpenTelemetry, Prometheus | Logs, traces, metrics — the three pillars. |
| Quality | TypeScript, Biome, ESLint | Types + linting + formatting, ideally in one fast tool. |
| AI | Claude, GitHub Copilot, Cursor | The 2026 productivity multiplier. |
| Deploy | Docker, Fly.io, Railway, Kubernetes | Docker everywhere. Fly/Railway for small teams. K8s for scale. |
The Habits That Compound
Read other people's code
15 minutes a day reading production code from open-source projects. Fastest way to level up.
Write one test per bug fix
The bug that never returns is worth more than the code that fixes it once.
Profile before optimizing
Your assumptions about where time goes are almost always wrong. Measure first.
Small, frequent deploys
The easiest way to make deploys safe is to make them boring. Ship daily.
Pair with AI daily
Not to write your code — to review it, test it, and challenge your assumptions.
Write about what you learn
Blog posts, PR descriptions, internal docs. Writing forces clarity.
Teach someone junior
Explaining something forces you to actually understand it. Mentor if you can.
Learn one new thing per week
A new library, a new pattern, a new debugging tool. Small, consistent growth beats intense bursts.
10 · Where to Go Next
JavaScript in 2026 is not the JavaScript of 2016 — and it will look different again in 2030. Here are the frontiers worth watching.
Bun
A drop-in Node.js replacement that's 3-5× faster. Includes a built-in bundler, test runner, and package manager. Watch this space.
Deno
Ryan Dahl's Node.js successor. Security-first, TypeScript-native, web-standard aligned. Deno 2 is production-ready.
Edge Runtime
V8 isolates on CDN edge nodes. Deploy JavaScript to 300+ locations. Cloudflare Workers, Vercel Edge, Deno Deploy.
WebAssembly
Run Rust, Go, or C++ in the browser at near-native speed. Increasingly common for compute-heavy JS workloads.
AI-First Frameworks
Next.js, SvelteKit, and others are adding first-class streaming AI primitives. The line between web app and AI app is blurring.
Server Components
React Server Components and their equivalents in other frameworks. The biggest architectural shift since SPA.
The Three Career Paths from Here
| Path | What You'll Do | Next Steps |
|---|---|---|
| Full-Stack Product Engineer | Build complete features end-to-end. Own the database, the API, the UI, and the deploy. | Learn a frontend framework deeply. Practice system design. Ship a side project. |
| Backend / Platform Engineer | Own the services, databases, queues, and infrastructure that everything else runs on. | Deep-dive Kubernetes, distributed systems, and database internals. Learn Go or Rust. |
| AI / ML Engineer | Build the systems around AI models — orchestration, evaluation, deployment, cost control. | Learn prompt engineering, RAG, vector databases, and evaluation frameworks. |
The honest truth about careers: the specific technologies change every 3-5 years. What doesn't change is the ability to learn deeply, debug systematically, write clearly, and ship reliably. The series you just finished didn't just teach you JavaScript — it taught you how to think about any system. That's the transferable skill.
11 · Final Quiz — 15 Questions Across the Entire Series
The final quiz covers material from all seven parts. Score 12 or higher and you've genuinely mastered the material. Score 15 and you're ready to teach it.
Part 7 — Final Series Quiz
15 questions. Every answer spans all seven parts.12 · Cheat Sheet & The End of the Series
The Complete Series Cheat Sheet
| Part | The One Thing to Remember |
|---|---|
| Part 1 — Execution Lifecycle | Sync → microtasks → one macrotask. Every async question resolves by asking "where does this fit in that order?" |
| Part 2 — Async Patterns | Promise.all for parallel, await for serial. Batch reads before writes. Abort everything you can. |
| Part 3 — Object Model | Prototypes are the reality; classes are sugar. this is determined at call site. Closures capture references, not values. |
| Part 4 — Modern Syntax | ?? over ||. ESM by default. Generators for streaming. WeakMap for metadata. |
| Part 5 — Browser Runtime | Batch reads and writes. Delegate events. Animate transform and opacity. Clean up listeners. |
| Part 6 — Testing & Types | One test per bug fix. Types at the boundaries. AI writes drafts — you own the final code. |
| Part 7 — Production | Observability, deployment safety, security, and the discipline to ship. The code is the easy part. |
The Do / Don't for the Entire Career
✅ DO
- Write code for the next person who reads it — often future-you.
- Test the fixes, not just the features.
- Profile before optimizing.
- Deploy small, deploy often, deploy safely.
- Log structured events with request IDs.
- Check authorization, not just authentication.
- Read error messages carefully — they usually tell you exactly what's wrong.
- Understand the code you ship. Every line.
- Teach what you learn. You'll understand it better.
- Stay curious. Every 3-5 years, the "best" tools change.
❌ DON'T
- Don't ship code you don't understand — even if AI wrote it.
- Don't trust coverage numbers as a proxy for correctness.
- Don't skip observability to ship faster. You'll pay it back with interest at 2 AM.
- Don't use
||for defaults when0or''are valid. - Don't deploy on Friday afternoons without a rollback plan.
- Don't treat security as "someone else's job".
- Don't argue about tabs vs spaces. Automate the decision.
- Don't be the only person who understands your service.
- Don't stop learning when you get comfortable.
- Don't forget why you started.
You Finished the Series
Seven parts. Dozens of chapters. Hundreds of concepts. Three complete capstone projects. When you started, JavaScript might have felt like a stranger — chaotic, unpredictable, a language that "just worked sometimes".
Now you understand it. Not because you memorized syntax — but because you understand the machine underneath. The event loop. The prototype chain. The rendering pipeline. The production concerns. The patterns that scale.
You can now read any JavaScript codebase with confidence. You can ship production services with tests, observability, and safety nets. You can pair with AI without losing control. You can architect systems, not just write functions.
That's not just "knowing JavaScript". That's being a senior engineer who happens to work in JavaScript. And it's exactly what you set out to become.
Now go build something worth shipping. 🚀
Part 7 of 7 · JavaScript for Backend Developers · The End · FreeLearning365.com
🌟 Continue Learning on FreeLearning365
Free tools, tutorials, and question banks for developers, students, and professionals.
- Learn Free ProgrammingJavaScript, Angular, Python, SQL, Data Analysis & More
- 100+ Free Online ToolsDevelopers, SEO Specialists & Daily Tasks
- Professional IT TrainingAdvance Your Career with Hands-On Courses
- AI Prompt Generator40+ Professional Prompt Types
- Drag & Drop Form GeneratorBootstrap 5.3/4, Custom CSS, Grid Layout
- Income Tax CalculatorNBR Slabs, Rebate & Minimum Tax
- NPS 2026 Salary Calculatorবাংলাদেশ জাতীয় বেতন স্কেল ২০২৬
- Electricity Bill CalculatorBERC Tariff & Appliance Report
- eBook CollectionFree for Download
- BCS / HSC / SSC Question Bankবাংলাদেশের সর্ববৃহৎ ফ্রি প্রশ্ন ব্যাংক
- AI Background RemoverRemove Image Background Free
- Free QR Code GeneratorCreate Custom QR Codes Online
- Barcode & Label GeneratorCustom Barcodes, QR Codes, A4 Sheets
- EV Class 9-10 All SubjectsPhysics, Chemistry, Biology, Math, ICT & BGS
- Our ServicesFull IT Solutions & Training
- Job Interview PreparationProgramming, Cloud, Data, ERP & More

0 Comments
thanks for your comments!