TypeScript / Node.js — Practical
Graceful shutdown
Section titled “Graceful shutdown”const shutdown = async (sig: string) => { server.close(async () => { await db.end(); await redis.quit(); process.exit(0); }); setTimeout(() => process.exit(1), 10_000).unref();};['SIGTERM','SIGINT'].forEach(s => process.on(s, () => shutdown(s)));Streams (backpressure-safe)
Section titled “Streams (backpressure-safe)”import { pipeline } from 'node:stream/promises';await pipeline( createReadStream('big.log'), createGzip(), createWriteStream('big.log.gz'),);Worker thread
Section titled “Worker thread”import { Worker } from 'node:worker_threads';const w = new Worker('./hash-worker.js', { workerData: { input } });w.on('message', (r) => console.log(r));Concurrency limit
Section titled “Concurrency limit”async function pLimit<T,R>(xs: T[], n: number, fn: (x:T)=>Promise<R>) { const out: R[] = []; let i = 0; await Promise.all(Array.from({length:n}, async () => { while (i < xs.length) { const idx = i++; out[idx] = await fn(xs[idx]); } })); return out;}Retry + jitter
Section titled “Retry + jitter”async function retry<T>(fn: ()=>Promise<T>, max=5, base=200): Promise<T> { for (let i = 0;; i++) { try { return await fn(); } catch (e) { if (i >= max) throw e; await new Promise(r => setTimeout(r, base * 2**i + Math.random()*base)); } }}Type-level snippets
Section titled “Type-level snippets”type UserId = string & { readonly __brand: 'UserId' };
type DeepReadonly<T> = T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
type Events = { login: [userId: string]; error: [err: Error] };class Emitter<E extends Record<string, any[]>> { on<K extends keyof E>(ev: K, fn: (...a: E[K]) => void) { /* ... */ }}Express + async errors
Section titled “Express + async errors”const wrap = (fn: any) => (req:any, res:any, next:any) => Promise.resolve(fn(req, res, next)).catch(next);
app.get('/users/:id', wrap(async (req, res) => { const u = await db.users.find(req.params.id); if (!u) return res.status(404).end(); res.json(u);}));Env validation (zod)
Section titled “Env validation (zod)”const Env = z.object({ PORT: z.coerce.number().default(3000), DATABASE_URL: z.string().url(), NODE_ENV: z.enum(['development','production','test']),});export const env = Env.parse(process.env);Request-scoped context
Section titled “Request-scoped context”import { AsyncLocalStorage } from 'node:async_hooks';const als = new AsyncLocalStorage<{ reqId: string }>();app.use((req,_,next) => als.run({ reqId: crypto.randomUUID() }, next));log.info({ reqId: als.getStore()?.reqId }, 'handling');Diagnostics
Section titled “Diagnostics”node --inspect=0.0.0.0:9229node --prof app.js && node --prof-process isolate-*.logclinic doctor -- node app.js0x app.jsautocannon -c 100 -d 30 URLPatterns
Section titled “Patterns”- AsyncLocalStorage for request context.
- Connection pool (pg-pool).
- Idempotency keys for POST.
- Circuit breaker:
opossum. - Outbox for reliable event publish.
Deep dive — Event Loop Phases
Section titled “Deep dive — Event Loop Phases”Source: Node.js — The Node.js Event Loop, Timers, and process.nextTick()
Technical explanation
Section titled “Technical explanation”The Node.js event loop is a libuv-driven loop that processes callbacks in six ordered phases on every tick: (1) Timers runs setTimeout/setInterval callbacks whose threshold has elapsed; (2) Pending callbacks runs deferred I/O callbacks (e.g. some TCP errors); (3) Idle, prepare is internal-only; (4) Poll retrieves new I/O events and executes their callbacks — this is where Node will block waiting for I/O when no timers are pending; (5) Check runs setImmediate() callbacks immediately after the poll phase; (6) Close callbacks runs things like socket.on('close', ...). Each phase has its own FIFO queue and the loop exhausts that queue (or hits a system-dependent limit) before moving on.
Outside these phases sit two queues that drain between every callback: the nextTickQueue (drained first) and the microtask queue / Promise jobs (drained second). Per the docs, “process.nextTick() is not technically part of the event loop” — its callbacks fire after the current operation completes, regardless of phase, and nextTick runs before Promise microtasks. This priority ordering — sync code → all process.nextTick callbacks → all Promise microtasks → next event-loop callback — is the single most-asked event-loop interview detail.
Code example
Section titled “Code example”import { setImmediate } from 'node:timers/promises';import { readFile } from 'node:fs';
console.log('1: sync');
setTimeout(() => console.log('5: setTimeout(0)'), 0);setImmediate(() => console.log('6: setImmediate'));
Promise.resolve().then(() => console.log('4: promise microtask'));process.nextTick(() => console.log('3: process.nextTick'));
readFile(__filename, () => { setTimeout(() => console.log('B: timer inside I/O'), 0); setImmediate(() => console.log('A: setImmediate inside I/O')); // always first});
console.log('2: sync');// Output: 1, 2, 3 (nextTick before microtasks), 4, 5/6 (non-deterministic), then A, BGotchas
Section titled “Gotchas”setImmediatevssetTimeout(fn, 0)outside I/O is non-deterministic — order depends on process startup timing. Inside an I/O callbacksetImmediateis guaranteed first (it runs in the very next Check phase, while the timer must wait a full loop).process.nextTickrecursion starves I/O. Recursively schedulingprocess.nextTick(self)prevents the loop from ever leaving the current operation — Node never reaches Poll. Same hazard applies to recursivequeueMicrotask/Promise.thenchains.- Node 20 / libuv 1.45.0 changed timer behavior: timers now run only after the Poll phase, not both before and after. This subtly changes timer-vs-immediate ordering in some scenarios.
- A “tick” is not a phase. “Tick” means one full trip around the loop.
process.nextTickis misnamed — it actually runs before the next phase boundary, not on the next tick. - Timer accuracy is a minimum threshold, not a guarantee.
setTimeout(fn, 100)means “no sooner than 100 ms.” A blocked event loop or a long Poll phase delays it.
Likely interview questions
Section titled “Likely interview questions”Q1: A teammate writes setImmediate(work) inside a hot HTTP handler to “yield to the event loop.” Is that correct? What if they used process.nextTick?
setImmediate is the correct primitive for yielding. It schedules work for the next Check phase, so the Poll phase can drain pending I/O callbacks (other inbound requests, DB responses) first — this is genuine cooperative scheduling. process.nextTick is the wrong tool: nextTick callbacks drain before the loop advances at all, so a chain of nextTick calls keeps the loop pinned in the current operation and starves I/O. The Node.js docs explicitly recommend setImmediate “in all cases because it’s easier to reason about.” Use nextTick only for deferring work to after the current synchronous stack but before any I/O — typical use case is emitting events from a constructor before listeners are attached.
Q2: Walk me through the order of execution: a Promise.resolve().then, a process.nextTick, a setImmediate, and a setTimeout(0) — all scheduled from the top of the script.
Synchronous code runs to completion first. Then, before the loop advances to its next phase, Node drains the nextTickQueue (so process.nextTick runs), then the microtask queue (so Promise.then runs). Only then does the loop enter its phases: it visits Timers (where setTimeout(0) likely fires, though it can be deferred a tick if 0 ms hasn’t actually elapsed), then Poll, then Check (where setImmediate fires). So a typical order is: nextTick → Promise.then → setTimeout → setImmediate. The setTimeout-vs-setImmediate order is non-deterministic at script top level but deterministic (setImmediate first) when both are scheduled inside an I/O callback, because the loop is already past Timers and lands in Check next.
Deep dive — Streams + Back-pressure
Section titled “Deep dive — Streams + Back-pressure”Source: Node.js — Stream API, particularly the pipeline, write(), and async-iterator sections.
Technical explanation
Section titled “Technical explanation”Node has four stream classes: Readable (data source, e.g. fs.createReadStream), Writable (sink, e.g. fs.createWriteStream), Duplex (both, e.g. net.Socket), and Transform (Duplex that mutates data, e.g. zlib.createGzip). Streams operate on Buffers/strings by default; passing { objectMode: true } switches them to arbitrary JS values (any non-null). The highWaterMark option is “a threshold, not a limit”: for byte streams it’s bytes buffered before back-pressure triggers (default 64 KiB / 65536 bytes since Node 22; was 16 KiB in Node ≤ 20), for object-mode streams it’s a count of objects (default 16).
Back-pressure is the contract that prevents fast producers from exhausting memory feeding slow consumers. writable.write(chunk) returns false when the internal buffer exceeds highWaterMark — the producer must stop and wait for the 'drain' event before writing more. readable.pipe(writable) automates this: it pauses the source when the sink is saturated, resumes it on 'drain'. The catch: if the source errors mid-pipe, the destination is not closed automatically, leaking file descriptors. Use stream.pipeline() (callback) or require('node:stream/promises').pipeline (Promise-based, since Node 15) — it propagates errors, cleans up all streams, and accepts AbortSignal for cancellation. Modern code consumes Readables with for await...of (each iteration awaits a chunk; the loop respects back-pressure naturally).
Code example
Section titled “Code example”import { createReadStream, createWriteStream } from 'node:fs';import { createGzip } from 'node:zlib';import { pipeline } from 'node:stream/promises';import { Transform } from 'node:stream';
const upperCase = new Transform({ transform(chunk, _enc, cb) { cb(null, chunk.toString().toUpperCase()); },});
async function gzipUppercase(src: string, dst: string, signal: AbortSignal) { await pipeline( createReadStream(src), upperCase, createGzip(), createWriteStream(dst), { signal }, );}
// Async-iterator consumption (back-pressure-aware)async function countLines(path: string): Promise<number> { let n = 0; for await (const chunk of createReadStream(path, { encoding: 'utf8' })) { n += (chunk.match(/\n/g) ?? []).length; } return n;}Gotchas
Section titled “Gotchas”.pipe()does not propagate errors. If the source errors, the destination stays open. Always usepipeline()in production.- Ignoring the
write()return value is the classic memory-leak pattern. Loopingfor (const x of huge) ws.write(x)without checking the boolean buffers everything in RAM. highWaterMarkis per stream, not global. A pipeline of 5 transforms can buffer 5 × HWM in flight.- Object-mode HWM defaults to 16 objects — surprisingly small. Tune up for high-throughput object pipelines (e.g. CSV parser → DB writer).
- Mixing
dataevents andfor await...ofon the same Readable is undefined. Once you attach a'data'handler, the stream switches to flowing mode and the iterator may miss chunks. pipeline()with async generators lets you write transforms inline:await pipeline(src, async function*(s) { for await (const c of s) yield transform(c); }, dst).
Likely interview questions
Section titled “Likely interview questions”Q1: How does back-pressure actually work in Node streams? Walk through the mechanism.
Every Writable maintains an internal buffer sized by highWaterMark. When you call writable.write(chunk), the chunk is appended; if the buffer is now larger than HWM, write() returns false. A well-behaved producer treats that false as a stop signal, stops calling write(), and listens once for the 'drain' event, which Node emits when the buffer drains below HWM. readable.pipe(writable) implements this for you: when the sink returns false, pipe calls readable.pause(), then resumes on 'drain'. With for await...of, back-pressure is implicit — the iterator only requests the next chunk when the loop body’s await completes. The async-iterator path is the modern preferred API because it makes back-pressure ergonomic.
Q2: Why prefer stream.pipeline() over .pipe().pipe()?
Three reasons. First, error propagation: pipe() does not destroy downstream streams when an upstream errors, which leaks file descriptors and sockets. pipeline() destroys all streams in the chain on any error. Second, completion semantics: pipeline() returns a Promise (via node:stream/promises) or accepts a callback with the final error/result, so you know exactly when the work finished — pipe() requires manually wiring 'finish' and 'error' on every stream. Third, cancellation: pipeline() accepts an AbortSignal since Node 15, so you can tear down a long-running transfer cleanly on request abort. In an HTTP handler streaming a gzipped DB query to the response, pipeline(dbCursor, gzip, res, { signal: req.signal }) gives you correct cleanup on client disconnect — pipe() does not.
Deep dive — worker_threads vs cluster vs child_process
Section titled “Deep dive — worker_threads vs cluster vs child_process”Sources: Node.js — Worker Threads, Node.js — Cluster. Pool reference: Piscina (Matteo Collina/Platformatic).
Technical explanation
Section titled “Technical explanation”Three primitives, three jobs. child_process spawns an entirely separate OS process (any executable) — use it for shelling out to external binaries (ffmpeg, git) or running another Node script in isolation. cluster is a thin wrapper over child_process.fork() specialized for HTTP scaling: the primary process owns the listening socket and round-robin-distributes accepted connections to N worker processes (one per core via os.availableParallelism()). Cluster is for I/O-bound HTTP scaling, not CPU work — each worker still has a single event loop, so a CPU-bound request still blocks one worker. worker_threads runs JS in true OS threads inside the same process, sharing the V8 heap’s infrastructure but with separate isolates. The docs are explicit: “Workers are useful for performing CPU-intensive JavaScript operations. They do not help much with I/O-intensive work.”
Workers communicate via MessagePort / MessageChannel using structured-clone postMessage. To avoid copying large buffers, pass them in the transferList — this transfers ownership (the sender loses access). SharedArrayBuffer is the only true shared-memory primitive; coordinate access with Atomics.wait/Atomics.notify to avoid races. Worker creation is expensive (~10–40 ms cold), so production code uses a pool — Piscina is the de-facto library, providing task queueing, idle eviction, and AsyncResource integration for proper async-stack tracing.
Code example
Section titled “Code example”// pool.ts (main thread) — using piscinaimport Piscina from 'piscina';import { resolve } from 'node:path';
const pool = new Piscina({ filename: resolve(__dirname, 'hash-worker.js'), maxThreads: 4,});
export async function hashPassword(pw: string): Promise<string> { return pool.run(pw); // queues; runs on next free worker}import { scrypt } from 'node:crypto';import { promisify } from 'node:util';const scryptAsync = promisify(scrypt);
export default async function (password: string): Promise<string> { const buf = (await scryptAsync(password, 'salt', 64)) as Buffer; return buf.toString('hex');}// Manual worker with transferable ArrayBufferimport { Worker, isMainThread, parentPort } from 'node:worker_threads';if (isMainThread) { const w = new Worker(__filename); const buf = new ArrayBuffer(1024 * 1024); w.postMessage(buf, [buf]); // transferred — buf.byteLength is now 0 here} else { parentPort!.on('message', (ab: ArrayBuffer) => { new Uint8Array(ab).fill(0xff); });}Gotchas
Section titled “Gotchas”- Cluster does not parallelize a single request. If
/reportdoes 2 s of CPU work, cluster lets you handle N concurrent reports (one per worker) but each one still blocks its worker for 2 s. SharedArrayBufferrequiresAtomicsfor correctness. Plain reads/writes from multiple threads are racy. UseAtomics.add,Atomics.compareExchange, etc., andAtomics.wait/notifyfor blocking coordination.postMessageuses structured clone, which doesn’t support functions, classes with prototypes, or DOM-like objects. It also deep-copies — pass big buffers intransferListinstead.- Workers don’t share
requirecache, globals, or modules. Each worker boots a fresh V8 isolate. Cold start is non-trivial; pool them. - Don’t use
clusterbehind a reverse proxy that already load-balances (e.g. Kubernetes with N pods). You’ll get N×N processes and unpredictable scheduling. One process per pod is usually right; let the orchestrator scale horizontally. - PM2’s “cluster mode” is just
node:cluster. Same caveats apply.
Likely interview questions
Section titled “Likely interview questions”Q1: An endpoint does heavy crypto/PDF generation and is blocking the event loop. How do you fix it — cluster, worker_threads, or child_process?
worker_threads with a pool. Cluster won’t help: it parallelizes across requests but each request still blocks one worker’s event loop, so p99 latency stays bad. child_process works but pays for full process spawn (~50–100 ms) and serializes data over a pipe. worker_threads runs the CPU work on an OS thread within the same process — the main event loop stays responsive for I/O — and transferList lets you move large buffers (rendered PDF bytes) to the main thread without copying. In practice use Piscina: it queues tasks, caps concurrency at os.availableParallelism(), recycles workers, and integrates with AsyncResource so async stack traces still work. The main-thread API stays a clean await pool.run(payload).
Q2: When would you actually reach for cluster over running multiple containers?
Cluster makes sense when you control the host directly — bare-metal, a single VM, or a single container that’s allocated multiple cores and you can’t (or don’t want to) run multiple containers. The primary process owns the listen socket and round-robins TCP accepts to workers, which is more efficient than an external load balancer for very high request rates because there’s no extra network hop. It also lets you do zero-downtime reloads by recycling workers one at a time. In a Kubernetes/Cloud Run world where the orchestrator already does load balancing and gives each pod one core, cluster is redundant — you scale horizontally with replicas. The Node docs explicitly note cluster is for network workloads; CPU work belongs in worker_threads.
Deep dive — TypeScript advanced types
Section titled “Deep dive — TypeScript advanced types”Sources: Conditional Types, Mapped Types, Narrowing, TS 4.9 satisfies, TS 5.0 decorators. Book: Effective TypeScript, Vanderkam, Item 14 (mapped types) and Item 50 (conditional types).
Technical explanation
Section titled “Technical explanation”The advanced TS toolkit centers on types as computations over types. Generics with constraints (<T extends { id: string }>) restrict a type parameter; the constraint is also visible inside the generic body for property access. Conditional types (T extends U ? X : Y) branch on type relationships; infer introduces a fresh type variable inside the true branch (e.g. T extends Promise<infer R> ? R : T). When the checked type is a naked generic parameter, the conditional distributes over unions: ToArray<string | number> becomes string[] | number[]. Wrap in tuples ([T] extends [U]) to disable distribution. Mapped types ({ [K in keyof T]: ... }) iterate keys and let you add/remove ? and readonly modifiers (-?, +readonly); key remapping via as (4.1+) supports template-literal renames like as `get${Capitalize<K & string>}` . Built-ins Pick, Omit, Partial, Required, Readonly, Record, ReturnType, Awaited are all expressible in this system.
The top/bottom types matter for safety: any opts out of checking entirely (poison — propagates); unknown is the safe top type — you must narrow before use; never is the bottom (no values), used for impossible branches and exhaustiveness. Discriminated unions with a literal kind/type tag give you exhaustive switch checking via a default: const _: never = x line — add a new variant and the assignment fails to compile. as const freezes literals ({ kind: "circle" } as const → { readonly kind: "circle" }). satisfies (4.9+) validates an expression against a type without widening it — so palette satisfies Record<Colors, string | RGB> catches typos but keeps palette.red as the literal tuple, not the union. Decorators come in two incompatible flavors: legacy/experimental (used by TypeORM, Sequelize, NestJS — needs experimentalDecorators: true and often emitDecoratorMetadata), and the new TC39 Stage 3 decorators in TS 5.0+ which use a (value, context) signature, are on by default, and are incompatible with emitDecoratorMetadata.
Code example
Section titled “Code example”// Generics with constraints + conditional types + infertype Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;
// Distributive vs non-distributivetype ToArray<T> = T extends any ? T[] : never;type A = ToArray<string | number>; // string[] | number[]type ToArrayOne<T> = [T] extends [any] ? T[] : never;type B = ToArrayOne<string | number>; // (string | number)[]
// Mapped + key remap + template literaltype Getters<T> = { [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];};type UG = Getters<{ name: string; age: number }>;// { getName: () => string; getAge: () => number }
// Discriminated union + exhaustivenesstype Shape = | { kind: 'circle'; r: number } | { kind: 'square'; side: number };
function area(s: Shape): number { switch (s.kind) { case 'circle': return Math.PI * s.r ** 2; case 'square': return s.side ** 2; default: { const _exhaustive: never = s; // compile error if a variant is added return _exhaustive; } }}
// satisfies preserves narrow typesconst config = { port: 3000, host: 'localhost',} satisfies Record<string, string | number>;config.port.toFixed(2); // OK — port still inferred as number, not string|number
// New TC39 decorator (TS 5.0+)function logged<This, Args extends any[], Return>( target: (this: This, ...args: Args) => Return, ctx: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,) { return function (this: This, ...args: Args): Return { console.log(`-> ${String(ctx.name)}`); return target.apply(this, args); };}class Service { @logged greet(n: string) { return `hi ${n}`; } }Gotchas
Section titled “Gotchas”anysilently disables checking on contact, including return types of functions it touches. Useunknownas the safe top type and narrow.- Naked-parameter distribution surprises:
NonNullable<T> = T extends null | undefined ? never : T— if you callNonNullable<string | null>, distribution yieldsstring | never=string. If you didn’t want distribution you’d need[T] extends [null | undefined]. satisfiesvs: Typevsas Type— annotation widens, assertion lies,satisfiesvalidates without widening. Knowing when to use each is a senior-level distinction.as constis shallow in the sense that it freezes literal values but doesn’t freeze nested arrays as readonly tuples unless they’re also literal in source. It also forces literal types for primitives.- Legacy and TC39 decorators are mutually exclusive: TS 5.0 only enables the new decorators when
experimentalDecoratorsis off. NestJS/TypeORM still require legacy mode as of 2026 — choose one per project. keyofwith index signatures returnsstring | number, not the literal keys.keyof Record<string, X>isstring, which can surprise mapped-type code.
Likely interview questions
Section titled “Likely interview questions”Q1: Explain unknown vs any vs never and when to use each.
any opts out of type checking — anything is assignable to any and any is assignable to anything, so it propagates and erases safety. Use it only at hard FFI boundaries, and ideally not even there. unknown is the type-safe top: anything is assignable to unknown, but unknown is assignable to nothing without narrowing — perfect for JSON.parse results, untrusted input, or generic library APIs where the consumer must validate before use. never is the bottom: no value inhabits it, and never is assignable to everything but nothing is assignable to it. It’s the return type of functions that throw or loop forever, the result of impossible conditional branches, and — most usefully — the trick behind exhaustiveness checking: assigning the discriminant variable to a never-typed local in a default clause forces a compile error if a new union variant is introduced.
Q2: What problem does the satisfies operator (TS 4.9) solve that type annotations and assertions can’t?
The tension is: you want to validate that a value matches a constraint, but you don’t want to widen the inferred type. A type annotation like const palette: Record<string, string | RGB> = { red: [255,0,0] } validates the keys/values but widens palette.red to string | RGB, so you can’t call array methods on it without narrowing. A type assertion like as Record<...> is even worse — it silently bypasses checking, so a typo like bleu instead of blue compiles. satisfies (4.9+) gives you both: the expression is checked against the constraint (catching typos and missing keys) but the inferred type is preserved, so palette.red is still [number, number, number]. It’s the right tool for typed config objects, route maps, command tables — anything where you want exhaustive validation against a schema while keeping per-key precision for downstream use.
Deep dive — Express middleware
Section titled “Deep dive — Express middleware”Sources: Express — Using middleware, Express — Error handling.
Technical explanation
Section titled “Technical explanation”Express middleware is (req, res, next) => void (or (err, req, res, next) for error handlers). Middleware runs in registration order along a per-request chain; each function must call next() to advance, send a response to terminate, or call next(err) to jump to the nearest error handler. app.use(fn) mounts globally; app.use('/api', fn) mounts at a path prefix; router.use(fn) does the same on a sub-Router that you mount with app.use('/admin', router). Sub-apps and Routers have their own middleware stacks and req.baseUrl/req.originalUrl reflect the mount point. next('route') skips the rest of the current route’s handler chain (only meaningful inside app.METHOD()); next('router') exits the router instance entirely and falls through to the parent app.
Error-handling middleware must declare exactly four parameters — Express identifies them by fn.length === 4. They must be registered after all routes/regular middleware. Pre-Express-5, async errors were not caught automatically: an async route that threw or rejected would log “UnhandledPromiseRejection” but never hit your error handler. The fix was either express-async-errors (monkey-patches the router) or wrapping every handler in a (req,res,next) => fn(req,res,next).catch(next) helper. Express 5 catches Promise rejections from async handlers automatically and forwards them to next(err) — a major reason to upgrade. Middleware factories (functions that return middleware) are the idiomatic way to parameterize behavior — requireRole('admin') returns a fresh middleware closure.
Code example
Section titled “Code example”import express, { Request, Response, NextFunction, Router, ErrorRequestHandler } from 'express';
const app = express();app.use(express.json());
// Middleware factory (parameterized)const requireRole = (role: 'admin' | 'user') => (req: Request, res: Response, next: NextFunction) => { if ((req as any).user?.role !== role) return next(new HttpError(403, 'forbidden')); next(); };
// Sub-app via Routerconst adminRouter = Router();adminRouter.use(requireRole('admin'));adminRouter.get('/users', async (req, res) => { res.json(await listUsers()); // Express 5: rejection auto-forwarded to error handler});app.use('/admin', adminRouter);
// Async wrapper for Express 4 compatibilityconst asyncHandler = <P, R, B>(fn: (req: Request<P, R, B>, res: Response<R>, next: NextFunction) => Promise<unknown>) => (req: Request<P, R, B>, res: Response<R>, next: NextFunction) => fn(req, res, next).catch(next);
app.get('/users/:id', asyncHandler(async (req, res) => { const u = await getUser(req.params.id); if (!u) throw new HttpError(404, 'not found'); res.json(u);}));
class HttpError extends Error { constructor(public status: number, msg: string) { super(msg); }}
// Error handler — MUST be 4 args, MUST be lastconst errorHandler: ErrorRequestHandler = (err, req, res, _next) => { const status = err instanceof HttpError ? err.status : 500; res.status(status).json({ error: err.message });};app.use(errorHandler);Gotchas
Section titled “Gotchas”- Drop the
nextparam and Express treats it as a regular middleware. A 3-arg function with(err, req, res)will not be invoked as an error handler — Express checksfn.length === 4. If you don’t usenext, prefix it_nextbut keep it. - Express 4 silently swallows async errors.
app.get('/x', async (req, res) => { throw new Error() })hangs the request until socket timeout. UseasyncHandlerwrapper or upgrade to Express 5. - Middleware order matters profoundly.
express.json()must be before any handler that readsreq.body.cors()must be before routes. Auth middleware must be before role checks. A misorderederrorHandlerregistered before routes will never fire. - Calling
next(err)afterres.send()will crash with “Cannot set headers after they are sent.” Alwaysreturn res.send(...)from terminal branches. next('route')only works insideapp.METHOD()chains, not insideapp.use()middleware — common interview trap.- Mounting paths strip the prefix from
req.urlbut preserve it inreq.originalUrl. Inside anapp.use('/api', router),req.urlis/usersbutreq.originalUrlis/api/users— important for logging.
Likely interview questions
Section titled “Likely interview questions”Q1: A teammate’s async Express route is silently failing — the response hangs and there’s no error log. What’s wrong and how do you fix it?
On Express 4, async route handlers that throw or whose awaited Promise rejects are not caught by the router. The promise rejects, Node logs an UnhandledPromiseRejection warning, and next(err) is never called — so your error handler never runs and the response never finishes (until the client times out). Three fixes: (1) wrap every async handler in a small asyncHandler = fn => (req,res,next) => fn(req,res,next).catch(next) utility — clean and explicit; (2) install express-async-errors once at startup, which monkey-patches the router to await handlers and forward rejections; (3) upgrade to Express 5, which awaits async handlers natively and forwards rejections to next(err) automatically. Option 3 is preferred for greenfield; option 1 for incremental adoption because it’s explicit and discoverable in code review.
Q2: Why does an Express error handler need exactly four arguments, and how would you structure error handling for a production API?
Express identifies error-handling middleware by inspecting fn.length === 4 — the (err, req, res, next) signature is the marker. A 3-arg function with the same body simply won’t be invoked on errors. For a production API I structure it as a chain of three handlers registered last: (1) a logging handler that records the error with request context (correlation ID, user ID, route) and calls next(err); (2) a known-error mapper that translates domain errors (HttpError, ZodError, NotFoundError) into status codes and safe public messages, calling res.status(...).json(...); (3) a catch-all for unknown errors that responds 500 with a generic message and never leaks stack traces to clients. I also throw typed HttpError subclasses from handlers rather than passing strings to next(), define a single asyncHandler (or use Express 5), and register handlers after all routes since middleware order determines reachability.
References
Section titled “References”- Event loop: https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick
- Streams: https://nodejs.org/api/stream.html (
stream.pipeline,'drain',highWaterMarksections) - Worker threads: https://nodejs.org/api/worker_threads.html
- Cluster: https://nodejs.org/api/cluster.html
- Conditional types: https://www.typescriptlang.org/docs/handbook/2/conditional-types.html
- Mapped types: https://www.typescriptlang.org/docs/handbook/2/mapped-types.html
- Narrowing / discriminated unions: https://www.typescriptlang.org/docs/handbook/2/narrowing.html
satisfies: https://devblogs.microsoft.com/typescript/announcing-typescript-4-9/- TC39 decorators: https://devblogs.microsoft.com/typescript/announcing-typescript-5-0/
- Express middleware: https://expressjs.com/en/guide/using-middleware.html
- Express error handling: https://expressjs.com/en/guide/error-handling.html
- Books: Node.js Design Patterns (Casciaro/Mammino, 3rd ed.) — Ch. 6 (Streams), Ch. 11 (Scaling); Effective TypeScript (Vanderkam) — Items 14, 50, 62; Programming TypeScript (Cherny) — Ch. 6 (Advanced Types).
- Worker pool reference implementation: Piscina by Matteo Collina (https://github.com/piscinajs/piscina).