Skip to content

TypeScript / Node.js — Practical

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)));
import { pipeline } from 'node:stream/promises';
await pipeline(
createReadStream('big.log'),
createGzip(),
createWriteStream('big.log.gz'),
);
import { Worker } from 'node:worker_threads';
const w = new Worker('./hash-worker.js', { workerData: { input } });
w.on('message', (r) => console.log(r));
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;
}
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 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) { /* ... */ }
}
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);
}));
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);
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');
Terminal window
node --inspect=0.0.0.0:9229
node --prof app.js && node --prof-process isolate-*.log
clinic doctor -- node app.js
0x app.js
autocannon -c 100 -d 30 URL
  • AsyncLocalStorage for request context.
  • Connection pool (pg-pool).
  • Idempotency keys for POST.
  • Circuit breaker: opossum.
  • Outbox for reliable event publish.

Source: Node.js — The Node.js Event Loop, Timers, and process.nextTick()

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.

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, B
  • setImmediate vs setTimeout(fn, 0) outside I/O is non-deterministic — order depends on process startup timing. Inside an I/O callback setImmediate is guaranteed first (it runs in the very next Check phase, while the timer must wait a full loop).
  • process.nextTick recursion starves I/O. Recursively scheduling process.nextTick(self) prevents the loop from ever leaving the current operation — Node never reaches Poll. Same hazard applies to recursive queueMicrotask/Promise.then chains.
  • 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.nextTick is 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.

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.


Source: Node.js — Stream API, particularly the pipeline, write(), and async-iterator sections.

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).

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;
}
  • .pipe() does not propagate errors. If the source errors, the destination stays open. Always use pipeline() in production.
  • Ignoring the write() return value is the classic memory-leak pattern. Looping for (const x of huge) ws.write(x) without checking the boolean buffers everything in RAM.
  • highWaterMark is 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 data events and for await...of on 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).

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).

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.

// pool.ts (main thread) — using piscina
import 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
}
hash-worker.ts
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 ArrayBuffer
import { 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);
});
}
  • Cluster does not parallelize a single request. If /report does 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.
  • SharedArrayBuffer requires Atomics for correctness. Plain reads/writes from multiple threads are racy. Use Atomics.add, Atomics.compareExchange, etc., and Atomics.wait/notify for blocking coordination.
  • postMessage uses structured clone, which doesn’t support functions, classes with prototypes, or DOM-like objects. It also deep-copies — pass big buffers in transferList instead.
  • Workers don’t share require cache, globals, or modules. Each worker boots a fresh V8 isolate. Cold start is non-trivial; pool them.
  • Don’t use cluster behind 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.

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.


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).

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.

// Generics with constraints + conditional types + infer
type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;
// Distributive vs non-distributive
type 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 literal
type 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 + exhaustiveness
type 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 types
const 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}`; } }
  • any silently disables checking on contact, including return types of functions it touches. Use unknown as the safe top type and narrow.
  • Naked-parameter distribution surprises: NonNullable<T> = T extends null | undefined ? never : T — if you call NonNullable<string | null>, distribution yields string | never = string. If you didn’t want distribution you’d need [T] extends [null | undefined].
  • satisfies vs : Type vs as Type — annotation widens, assertion lies, satisfies validates without widening. Knowing when to use each is a senior-level distinction.
  • as const is 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 experimentalDecorators is off. NestJS/TypeORM still require legacy mode as of 2026 — choose one per project.
  • keyof with index signatures returns string | number, not the literal keys. keyof Record<string, X> is string, which can surprise mapped-type code.

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.


Sources: Express — Using middleware, Express — Error handling.

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.

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 Router
const 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 compatibility
const 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 last
const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
const status = err instanceof HttpError ? err.status : 500;
res.status(status).json({ error: err.message });
};
app.use(errorHandler);
  • Drop the next param 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 checks fn.length === 4. If you don’t use next, prefix it _next but keep it.
  • Express 4 silently swallows async errors. app.get('/x', async (req, res) => { throw new Error() }) hangs the request until socket timeout. Use asyncHandler wrapper or upgrade to Express 5.
  • Middleware order matters profoundly. express.json() must be before any handler that reads req.body. cors() must be before routes. Auth middleware must be before role checks. A misordered errorHandler registered before routes will never fire.
  • Calling next(err) after res.send() will crash with “Cannot set headers after they are sent.” Always return res.send(...) from terminal branches.
  • next('route') only works inside app.METHOD() chains, not inside app.use() middleware — common interview trap.
  • Mounting paths strip the prefix from req.url but preserve it in req.originalUrl. Inside an app.use('/api', router), req.url is /users but req.originalUrl is /api/users — important for logging.

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.