Node.js & Express — Interview Questions

Read notes

Answered the way you would say it in the room — first person, with an example every time. Tap a question to reveal the answer. Levels: Basic, Intermediate, Advanced.

Part 1 — Node Basics

1. What Node.js Actually Is

BasicWhat is Node.js? Is it a language, a framework, or something else?

So Node.js is a JavaScript runtime. It's the program that runs JavaScript outside the browser and gives it server-side tools. It isn't a language, because it's just JavaScript. It isn't a framework either, because it doesn't tell me how to structure my app. Express, Fastify and NestJS are the frameworks that sit on top of it.

The way I explain it: in the browser, JavaScript gets window and document. In Node those are gone, and instead I get file access, networking, processes and crypto.

// This runs in Node, not in a browser
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";

const config = JSON.parse(await readFile("config.json", "utf8"));

createServer((req, res) => {
  res.end(`Hello from ${config.name}`);
}).listen(3000);

What happens here is that I read a file from disk and open a TCP port. A browser can't do either of those. The language is identical, but the environment is completely different.

Under the hood Node is really three things: V8 to execute the JavaScript, libuv to talk to the operating system for I/O and the event loop, and a set of built-in modules like fs, http and crypto.

Interviewers look for: the word "runtime", and a clear line between Node (the runtime) and Express (a framework on top of it).

BasicWhat are V8 and libuv, and what does each one do in Node?

So V8 is the JavaScript engine. It's the same one Chrome uses, and it parses my code, compiles it to machine code with its JIT, and manages memory and garbage collection. libuv is a C library that handles everything that involves waiting: the event loop, network sockets, timers, file I/O and the thread pool.

Let me give you an example of how they split the work:

import { readFile } from "node:fs/promises";

const total = [1, 2, 3].reduce((a, b) => a + b, 0);   // V8: pure computation
const text = await readFile("data.txt", "utf8");      // libuv: hands the read to its thread pool
console.log(total, text.length);                      // V8 again, once the callback is queued

What happens here is that V8 runs the reduce on the main thread. When I call readFile, Node's binding layer passes that job to libuv, which runs it on a thread-pool thread. V8 is free to run other code in the meantime. When the read finishes, libuv queues the callback, and the event loop hands it back to V8 to run.

The bit that connects them is Node's C++ bindings, which is how fs.readFile in JavaScript ends up as a libuv call.

So in practice: if something is slow because of computation, that's a V8 or main-thread problem. If it's slow because of waiting, that's libuv's job, and Node handles it well.

BasicWhat kinds of workloads is Node a good fit for, and where would you not use it?

So Node shines when the work is mostly waiting: waiting on a database, another API, a file, or a socket. It struggles when the work is mostly computing, because all my JavaScript runs on one thread.

Good fitPoor fit
REST / GraphQL APIs, BFF layersVideo transcoding, image processing in-process
Real-time: chat, WebSockets, live dashboardsHeavy numeric work, ML training
Proxies, gateways, streaming uploadsLong CPU-bound batch jobs on the request path

Let me give you a concrete one. In my last project we had a notifications service. Each request was about 2ms of JavaScript and 40ms of waiting on Postgres and Redis. One Node process handled thousands of concurrent connections without trouble, because while one request waited, the thread served the others.

Then someone added PDF generation directly in a route handler, and p99 latency for every endpoint jumped, because that CPU work held the one thread. We moved it to a BullMQ worker, and the API went back to normal.

So in practice I don't say "Node can't do CPU work". I say CPU work doesn't belong on the request thread. Worker threads, a job queue, or a separate service in Go or Rust are all fine answers.

Interviewers look for: the reasoning (I/O-bound versus CPU-bound, one thread), not just a memorised list.

IntermediateWhat's the difference between JavaScript in the browser and in Node?

So the language is the same ECMAScript. What differs is the host environment: the global objects, the APIs, the module system defaults, and the security model.

// Browser only
window.location.href;
document.querySelector("#app");
localStorage.getItem("token");

// Node only
process.env.DATABASE_URL;
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";

// Both (Node has adopted many web standards)
await fetch("https://api.example.com");
new URL("/users", "https://example.com");
structuredClone(obj);
globalThis;   // the portable name for the global object

The main points I'd call out are these. The browser is sandboxed, so it can't touch the file system. Node runs with the permissions of the user who started it, so it can do anything that user can. In the browser the global is window; in Node it's global, and globalThis works in both.

One thing that has changed a lot in recent years is that Node now ships web-standard APIs: fetch, AbortController, URL, Web Streams, crypto.subtle and structuredClone. So a lot of code is now portable without polyfills.

So in practice, when I write shared code, for example validation that runs on both client and server, I stick to globalThis and web-standard APIs.

IntermediateHow do Node, Deno and Bun compare? Would you pick one of the newer runtimes for a new project?

So all three are JavaScript runtimes, and the differences are in the engine, the tooling that comes built in, and ecosystem maturity. Node uses V8 and libuv. Deno also uses V8 but is written in Rust, with permissions on by default and TypeScript built in. Bun uses JavaScriptCore, is written in Zig, and bundles a package manager, a test runner and a bundler, with a focus on speed.

NodeDenoBun
EngineV8V8JavaScriptCore
TypeScriptType stripping built in on current versionsNativeNative
SecurityOpt-in permission modelDeny-by-default permissionsNone by default
npm compatibilityCompleteVery goodVery good

For a production service at a company, I'd still default to Node, usually the current LTS line. The reason isn't performance. It's operational: every APM agent, cloud platform, Docker base image and library is tested against Node first, and the team already knows how to debug it.

That said, Node has taken a lot of good ideas from the other two: a built-in test runner, --watch, --env-file, and running .ts files directly. So the gap is smaller than it was.

Where I've actually used Bun is for local scripts and as a fast package manager in CI. For the runtime itself, I'd want a specific reason before moving away from Node.

Interviewers look for: a pragmatic answer based on the ecosystem and the team, not hype about benchmarks.

AdvancedIf Node runs JavaScript on one thread, how can one process handle 10,000 concurrent connections?

So the key is that "concurrent" doesn't mean "computing at the same time". Almost all of those 10,000 connections are idle at any moment, waiting on the network. Node doesn't need a thread per connection to wait. It asks the OS to watch all the sockets and tell it when one has data.

import { createServer } from "node:http";

createServer(async (req, res) => {
  const user = await db.query("SELECT ...");   // ~20ms waiting, ~0ms of thread time
  res.end(JSON.stringify(user));
}).listen(3000);

What happens here is that libuv registers every socket with the kernel's readiness API: epoll on Linux, kqueue on macOS, IOCP on Windows. Each open connection is just a file descriptor plus a small object in memory, a few kilobytes. The event loop blocks in the poll phase until the kernel reports that some sockets are readable, and then it runs those callbacks one after another.

Compare that to the old thread-per-request model. Each thread reserves its own stack, often around 1MB by default, and the OS spends time context-switching between thousands of mostly sleeping threads.

The catch is that this only works while each callback is short. If one request spends 200ms computing, the other 9,999 wait 200ms. So the real limit on a Node server is usually CPU time per request and event-loop lag, not the number of connections. That's why I monitor eventLoopUtilization or event-loop delay in production.

Interviewers look for: epoll or kqueue, "waiting is cheap", and the honest caveat that one slow callback delays everyone.

2. How Node Runs Your Code

BasicIs Node.js single-threaded?

So the honest answer is: my JavaScript runs on a single main thread, but the Node process is not single-threaded. There's the libuv thread pool (4 threads by default), V8's own background threads for garbage collection and compilation, and I can create more myself with worker threads.

// Only one of these callbacks runs at any instant
app.get("/a", (req, res) => res.send("a"));
app.get("/b", (req, res) => res.send("b"));

// But this hashing runs on a thread-pool thread, off the main thread
crypto.pbkdf2(password, salt, 310_000, 32, "sha256", (err, key) => { ... });

What that means in practice is that I never need locks or mutexes around ordinary JavaScript variables, because two callbacks can't interleave in the middle of a function. A function runs to completion before anything else gets the thread.

The trade-off is the flip side of the same fact: if one callback takes a long time, nothing else runs.

Interviewers look for: "single-threaded for your JavaScript, multi-threaded underneath". A flat "yes" or a flat "no" both lose points.

BasicWhat is a callback, and what happens when you call something like fs.readFile?

So a callback is a function I hand to an API, saying "call this back when you're done". With async I/O in Node, the call returns straight away, and the callback runs later, when the result is ready.

import fs from "node:fs";

console.log("1. before");
fs.readFile("data.txt", "utf8", (err, text) => {
  if (err) return console.error(err);
  console.log("3. file loaded", text.length);
});
console.log("2. after");

// 1. before
// 2. after
// 3. file loaded ...

What happens here, step by step: readFile passes the read to libuv, which runs it on a thread-pool thread. readFile returns undefined right away, so "2. after" prints next. When the read finishes, libuv puts the callback in a queue. On its next pass the event loop picks it up, and "3." prints.

Node's classic callbacks follow the error-first convention: the first argument is the error (or null), then the result. That's why every callback starts with if (err).

In new code I use the promise versions, fs/promises and await, because they're easier to compose and handle errors in. But the mechanism underneath is the same.

IntermediateWhat is the libuv thread pool? Which operations use it and which don't?

So the thread pool is a small set of worker threads, 4 by default, that libuv uses for operations the OS can't do asynchronously for it. Network I/O does not use it. The kernel handles sockets through epoll or kqueue, so thousands of connections need no threads at all.

Uses the thread poolDoes not
Most fs operationsTCP/HTTP sockets
dns.lookup (and so http.get("hostname"))dns.resolve* (uses c-ares on the network)
Async crypto: pbkdf2, scrypt, randomBytesTimers
zlib async compressionPipes, child process I/O

Let me show you the limit. I ran six pbkdf2 hashes at once:

for (let i = 1; i <= 6; i++) {
  crypto.pbkdf2("pw", "salt", 300_000, 64, "sha512", () =>
    console.log(`hash ${i}: ${Date.now() - start}ms`));
}
// hashes 1-4: ~125ms   hashes 5-6: ~250ms   (default pool of 4)
// with UV_THREADPOOL_SIZE=8: all six ~135ms

The first four finish together, and the last two queue behind them. The pool is shared by the whole process, so a burst of password hashing can delay unrelated file reads.

So in practice, if a service does a lot of fs or crypto work, I set UV_THREADPOOL_SIZE (the maximum is 1024). I set it as an environment variable before the process starts, because the pool is created on first use.

Interviewers look for: knowing that network I/O doesn't use the pool, and the dns.lookup gotcha.

IntermediateWhat's the difference between concurrency and parallelism, and which one does Node give you?

So concurrency is dealing with many things at once: making progress on several tasks by switching between them. Parallelism is doing many things at the exact same instant on multiple cores. For JavaScript, Node gives me concurrency by default and parallelism only when I ask for it.

// Concurrency: three requests in flight, one thread waiting on all of them
const [user, orders, prefs] = await Promise.all([
  fetch(`${API}/user/1`),
  fetch(`${API}/orders?user=1`),
  fetch(`${API}/prefs/1`),
]);

// Parallelism: actual work on another core
import { Worker } from "node:worker_threads";
const worker = new Worker("./resize.js", { workerData: imagePath });

In the first example, the total time is roughly the slowest of the three calls, not the sum. That's not because anything runs in parallel in my code. It's because the waiting overlaps.

In the second, the resize truly runs on another CPU core, with its own V8 isolate and event loop.

At the process level, I get parallelism by running several Node processes: the cluster module, PM2, or more commonly in 2025, several containers or pods behind a load balancer. That uses every core without changing the code.

AdvancedWhen would you use worker threads versus child processes versus cluster?

So I pick based on what I'm trying to parallelise. Worker threads are for CPU-heavy JavaScript inside one service. Child processes are for running a different program. Cluster is for running more copies of the whole server so it can use every core.

// worker_threads: offload CPU work, share memory if needed
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";

if (isMainThread) {
  const w = new Worker(new URL(import.meta.url), { workerData: 1e7 });
  w.on("message", (sum) => console.log("sum", sum));   // sum 49999995000000
} else {
  let s = 0;
  for (let i = 0; i < workerData; i++) s += i;
  parentPort.postMessage(s);
}

// child_process: run another binary
import { execFile } from "node:child_process";
execFile("ffmpeg", ["-i", "in.mp4", "out.webm"], callback);

What makes these different: a worker thread is lightweight, runs in the same process, and can share memory through SharedArrayBuffer or transfer ArrayBuffers without copying. A child process is fully isolated, so a crash there doesn't take down my server. Cluster forks copies of my app that share one listening port.

One gotcha I've hit is creating a new Worker per request. Starting one costs tens of milliseconds and some megabytes of memory, so I use a pool, for example piscina.

So in practice: for image resizing I'd use a worker pool. To call ffmpeg or a Python script I'd use execFile or spawn, never exec with user input, because that goes through a shell. For using all cores in production, I usually skip cluster and let Kubernetes run more replicas, because that also gives me isolation and rolling deploys.

Interviewers look for: pooling workers, isolation trade-offs, and awareness that orchestration has mostly replaced cluster.

3. Blocking vs Non-Blocking

BasicWhat's the difference between blocking and non-blocking code in Node?

So blocking code holds the main thread until it finishes, and because there's only one thread for my JavaScript, it holds up every request, not just the one that triggered it. Non-blocking code starts the operation, returns straight away, and lets Node keep serving other requests until the result comes back.

import fs from "node:fs";

// Blocking: the whole server is stuck until the read finishes
app.get("/sync", (req, res) => {
  const data = fs.readFileSync("big.json", "utf8");
  res.send(data);
});

// Non-blocking: other requests are served during the read
app.get("/async", async (req, res) => {
  const data = await fs.promises.readFile("big.json", "utf8");
  res.send(data);
});

What happens with the first route is that, if the file takes 50ms to read and 100 users hit it, they are served one after another. The last one waits five seconds, and health checks on other routes time out too.

The one place I'm happy to use sync calls is startup, for example readFileSync on a config file before app.listen. No traffic is being served yet, so nobody waits.

Interviewers look for: "it blocks everyone, not just this request", and the startup exception.

BasicDoes marking a function async make it non-blocking?

So no, and this is the most common misconception I see. async only means the function returns a promise and can use await. The code inside still runs on the main thread. If that code is a heavy calculation, it blocks exactly as much as before.

app.get("/report", async (req, res) => {
  let total = 0;
  for (let i = 0; i < 5e9; i++) total += i;   // several seconds of CPU
  res.json({ total });
});

What happens here is that the whole loop runs synchronously before the function even reaches its first await, and there isn't one anyway. Every other request waits.

Let me give you a proof I actually ran:

const start = Date.now();
setTimeout(() => console.log("timer fired after", Date.now() - start, "ms"), 0);
while (Date.now() - start < 2000) {}   // busy for 2 seconds
// timer fired after 2000+ ms, even though it was scheduled for 0

async/await helps when I'm waiting on something outside my code, like a database or an HTTP call, because the waiting happens elsewhere. It can't split computation across threads. For that I need worker threads or a job queue.

IntermediateWhich operations in a typical Express app block the event loop without people realising it?

So the obvious ones are ...Sync functions, but the ones that actually cause incidents are hidden inside normal-looking code. There are really five I check for:

  1. JSON.parse / JSON.stringify on large payloads. Serialising a 50MB response can take hundreds of milliseconds.
  2. Catastrophic regex backtracking (ReDoS). A pattern like /^(a+)+$/ on a long input that doesn't match is exponential.
  3. Synchronous crypto, like bcrypt.hashSync, crypto.pbkdf2Sync or scryptSync inside a login route.
  4. Big in-memory work: sorting or filtering 100k rows in JavaScript instead of in the database.
  5. Sync logging or console.log in a hot path when stdout is a file or a slow pipe. Pino is fast partly because it avoids this.
// Looks innocent, but blocks the loop on a hostile input
const emailRe = /^([a-zA-Z0-9]+)*@example\.com$/;
emailRe.test("a".repeat(40) + "!");   // exponential backtracking

In my last project a single /export endpoint was calling JSON.stringify on a huge array. During month-end, the p99 for the whole service went up to seconds. We changed it to stream the rows out as NDJSON, and the latency spike disappeared.

So in practice I look for these in code review, and I keep an event-loop-delay metric on the dashboard so I can tell when one gets through.

Interviewers look for: ReDoS and large JSON in particular. Mentioning event-loop monitoring shows production experience.

IntermediateYou need to process a list of 100,000 items in a request. How do you keep the server responsive?

So first I'd question whether it belongs in the request at all. If it does, I have three options depending on the cost: break it into chunks and yield between them, move it to a worker thread, or push it onto a job queue and return straight away.

// Option 1: chunk and yield so other callbacks get a turn
import { setImmediate as yieldToLoop } from "node:timers/promises";

async function processAll(items) {
  for (let i = 0; i < items.length; i++) {
    transform(items[i]);
    if (i % 1000 === 0) await yieldToLoop();   // let I/O and other requests run
  }
}

What happens here is that every 1000 items I give the thread back. setImmediate is the right tool because it runs in the check phase, after pending I/O has been handled. If I used process.nextTick or await Promise.resolve() instead, it wouldn't yield at all. Those are microtasks, so they run before the loop can move on.

Chunking keeps the server responsive, but the total work still uses the main thread's CPU. If the job is genuinely heavy, I'd use a worker pool. If it takes longer than a few seconds, I'd make it a background job: return 202 Accepted with a job id, process it in BullMQ, and let the client poll or get a webhook.

So in practice: chunking for "tens of milliseconds", workers for "hundreds of milliseconds", a queue for "seconds or more".

AdvancedYour Node API's latency spikes randomly, but the CPU isn't maxed out. How do you find out whether the event loop is blocked?

So the first thing I do is measure event-loop delay directly, because average CPU usage hides it. A 300ms block once a minute barely moves the CPU graph but ruins p99 latency.

import { monitorEventLoopDelay, performance } from "node:perf_hooks";

const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();

setInterval(() => {
  const elu = performance.eventLoopUtilization();
  metrics.gauge("eventloop.delay.p99_ms", h.percentile(99) / 1e6);
  metrics.gauge("eventloop.utilization", elu.utilization);
  h.reset();
}, 10_000).unref();

If the p99 delay lines up with the latency spikes, the loop is being blocked. Then I need to find what is blocking it, and I use three tools:

  1. A CPU profile. node --cpu-prof, or attaching Chrome DevTools with --inspect, or a flame graph from Clinic.js or 0x. The wide bars on the flame graph are the culprits.
  2. Tracing per request through OpenTelemetry. That shows which route was running just before the gap.
  3. Checking GC with --trace-gc. Long major GC pauses look exactly like a blocked loop, and they come from a heap that's too large or leaking.

In one case we found it was a zod schema parsing a 20MB payload on a webhook endpoint. We added a size limit and moved the parsing into a worker.

Interviewers look for: a method, which is measure, then profile, then fix. Naming monitorEventLoopDelay or ELU and flame graphs is a strong signal.

4. The Event Loop

BasicWhat is the event loop?

So the event loop is the mechanism that lets Node run async code on one thread. It keeps asking the OS "has anything finished?", and when something has, it runs that callback on the main thread. Then it asks again. That repeats until there's nothing left to wait for, and then the process exits.

console.log("start");

setTimeout(() => console.log("timer"), 0);
fs.readFile("a.txt", () => console.log("file read"));

console.log("end");

// start
// end
// timer       (usually)
// file read

What happens here is that the synchronous code runs first, all of it, so "start" and "end" print. Only then does the event loop start taking callbacks from its queues. The timer is due immediately, so it's picked up in the timers phase. The file read finishes a bit later and is picked up in the poll phase.

The important rule is that a callback always runs to completion. The loop can't interrupt it. That's what makes Node simple to reason about, and it's also why one slow callback hurts everyone.

Interviewers look for: "sync code first, then callbacks from queues, one at a time". The phases usually come up as the follow-up.

BasicWhat are the phases of the Node event loop?

So each lap of the loop goes through six phases in a fixed order, and each phase has its own queue of callbacks:

  1. timers: setTimeout and setInterval callbacks whose time is up.
  2. pending callbacks: a few system-level callbacks deferred from the previous lap, like some TCP errors.
  3. idle / prepare: internal only. My code never runs here.
  4. poll: callbacks for completed I/O, like incoming requests and file reads. If nothing else is scheduled, the loop waits here for new events.
  5. check: setImmediate callbacks.
  6. close callbacks: things like socket.on("close").

And between every callback, not just between phases, Node empties the microtask queues: process.nextTick first, then promises.

setTimeout(() => {
  console.log("t1");
  Promise.resolve().then(() => console.log("p1"));
}, 0);
setTimeout(() => console.log("t2"), 0);

// t1
// p1   (microtasks drain between the two timer callbacks)
// t2

That output is from Node 11 onward. Before that, both timers ran first and then p1. Node changed it to match browser behaviour.

So in practice the ones I actually think about are timers, poll and check, plus the microtask queues.

IntermediateWhat does this print, and why?
console.log("1 sync");
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("2 sync");

So in a CommonJS file I'd expect: 1 sync, 2 sync, nextTick, promise, then timeout and immediate.

Walking through it: synchronous code always runs first, so both "sync" lines print. Then, before the loop starts, Node drains the nextTick queue and then the promise microtask queue. Then the loop starts: timers phase, then check phase.

But there's a catch I'd call out: at the top level of the main module, timeout versus immediate is not guaranteed. setTimeout(fn, 0) is really 1ms. If the loop reaches the timers phase before 1ms has passed, the timer isn't due yet, and immediate wins. It depends on how fast the process started.

Inside an I/O callback, though, the order is deterministic:

fs.readFile(__filename, () => {
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));
});
// immediate
// timeout   (always: check comes right after poll)

There, we're in the poll phase, and check comes next, before the loop goes back round to timers.

Interviewers look for: the right order, plus the "not guaranteed at top level" caveat. Most candidates miss that part.

IntermediateWhat's the difference between process.nextTick, setImmediate and setTimeout(fn, 0)?

So the names are confusing, because setImmediate is less immediate than nextTick. nextTick runs as soon as the current operation finishes, before the loop moves on at all. setImmediate runs in the check phase of the current or next lap. setTimeout(fn, 0) runs in the timers phase once at least 1ms has passed.

Where it runsCan starve I/O?
process.nextTicknextTick queue, before promisesYes
queueMicrotask / .thenPromise microtask queueYes
setImmediateCheck phaseNo
setTimeout(fn, 0)Timers phase, at least 1ms laterNo
// This hangs the process: the loop never gets past microtasks
function spin() { process.nextTick(spin); }
spin();

// This is fine: each call yields to I/O before the next one
function tick() { setImmediate(tick); }
tick();

The main legitimate use of nextTick is inside libraries, to make a callback always async. For example, you can emit an error on an EventEmitter after the constructor has returned, so the caller has had time to attach a listener.

So in practice, in application code I use setImmediate when I want to yield, and queueMicrotask when I need "right after this, before anything else". I almost never reach for nextTick.

IntermediateWhat is event-loop starvation, and how can you cause it by accident?

So starvation is when the loop can't reach its next phase because something keeps it busy. The result is that I/O callbacks, timers and new requests never get their turn. There are two ways it happens: a long synchronous task, or an endless chain of microtasks.

The microtask version is the sneaky one, because it looks async:

// Looks like it yields, but it never lets I/O run
async function drain(queue) {
  while (queue.length) {
    await handle(queue.shift());   // handle() resolves immediately when cached
  }
}

What happens here is that if handle returns an already-resolved promise, each await continues as a microtask. Microtasks are drained completely before the loop moves on, so if the queue keeps getting new items, the poll phase is never reached. Incoming requests just sit there.

I hit a version of this with an in-memory cache. Cache hits resolved synchronously, and a batch job of 200k cached lookups froze the API for about four seconds, even though every line had an await.

The fix is to yield to the macrotask queue now and then:

import { setImmediate as yieldToLoop } from "node:timers/promises";
if (++n % 500 === 0) await yieldToLoop();

Interviewers look for: understanding that await does not automatically mean "lets other requests run".

AdvancedIn an ES module, the order of nextTick and a promise at the top level is different from CommonJS. Why?

So this one surprised me when I first saw it. The same three lines give different output depending on the module system:

Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("sync");

// app.cjs:  sync, nextTick, promise
// app.mjs:  sync, promise, nextTick

What's going on is that a CommonJS module runs as a plain synchronous function call. When it returns, Node drains the nextTick queue first and then the promise queue, which is the documented priority.

An ES module is evaluated by V8 as part of an async module graph. ESM evaluation is promise-based, because of top-level await. So my top-level code is effectively already running inside a microtask. When it finishes, V8 keeps draining the microtask queue, which includes my .then, before control returns to Node. Only then does Node get a chance to process the nextTick queue.

The practical takeaway is not "memorise this". It's that code shouldn't depend on the relative order of nextTick and promises. If two things must happen in a specific order, I chain them explicitly.

It matters most when migrating a codebase from CommonJS to ESM. I've seen a test that asserted on log order start failing after the switch, and it was exactly this.

Interviewers look for: knowing that ESM evaluation is itself async. It's a senior-level detail, and "don't depend on it" is the right conclusion.

5. Modules

BasicWhat's the difference between CommonJS and ES Modules?

So CommonJS is Node's original module system, with require and module.exports. ES Modules is the language standard, with import and export, and it's what browsers use. The big differences are when things load and what the exports actually are.

// CommonJS
const express = require("express");
module.exports = { createUser };

// ES Modules
import express from "express";
export function createUser() { ... }

There are really four differences I'd point out:

  1. Loading. require is synchronous and runs when that line executes, so I can call it conditionally. import declarations are resolved and linked before any code in the module runs.
  2. Top-level await only works in ESM.
  3. __dirname and __filename don't exist in ESM. On current Node I use import.meta.dirname and import.meta.filename.
  4. Static structure. ESM imports can be analysed without running the code, which is what makes tree shaking possible.

I turn on ESM with "type": "module" in package.json, or use the .mjs extension for a single file. .cjs forces CommonJS.

So in practice, for any new project I start with ESM. It's the standard, and the ecosystem has mostly moved there.

BasicWhat is the module cache, and what does it mean for something like a database connection?

So Node runs each module file only once, the first time it's loaded. After that it stores the result in a cache, and every later require or import of the same resolved file gets the same object back. That makes a module a natural singleton.

// db.js
import pg from "pg";
console.log("creating pool");
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

// users.js, orders.js, auth.js all do:
import { pool } from "./db.js";
// "creating pool" prints once, and all three share one pool

That's exactly what I want for a connection pool, a logger or a config object. I don't need a DI container just to share one instance.

There are two gotchas. First, shared mutable state: if one module does config.debug = true, every other module sees it. Second, the cache key is the resolved file path. If the same package ends up installed twice in node_modules at different versions, I get two separate instances. That's how you end up with "two copies of React" or an instanceof check that fails.

Interviewers look for: "module equals singleton", and the duplicate-package gotcha.

IntermediateWhat's the difference between module.exports and exports?

So exports is just a shortcut variable that starts out pointing at the same object as module.exports. What require actually returns is module.exports. So adding properties through exports works, but reassigning exports breaks the link.

// Works: both names point to the same object
exports.add = (a, b) => a + b;

// Broken: exports now points to a new object, module.exports is still {}
exports = { add };

// Correct way to export a single thing
module.exports = function add(a, b) { return a + b; };

What happens under the hood is that Node wraps every CommonJS file in a function, roughly (function (exports, require, module, __filename, __dirname) { ... }). So exports is a parameter. Reassigning a parameter never changes the caller's object.

That same wrapper is also why __dirname and require exist in CommonJS but not in ESM. They aren't globals; they're arguments to that wrapper.

So in practice I always write module.exports = { ... } in CommonJS files, so there's no confusion.

IntermediateCan you use CommonJS and ES Modules together? How?

So yes, but the rules aren't symmetric, and they changed recently.

ESM importing CommonJS has always worked. module.exports becomes the default export, and Node tries to detect named exports too:

// in an .mjs file
import express from "express";              // CJS package, fine
import { Router } from "express";           // named export, usually detected

CommonJS requiring ESM used to be impossible. You had to use dynamic import(), which is async. From Node 22.12, and backported to 20.19, require() of a synchronous ES module works by default:

// cjs file
const mod = require("./m.mjs");   // [Module: null prototype] { x: 1 }

// Still not allowed: requiring an ESM file that uses top-level await
// -> throws ERR_REQUIRE_ASYNC_MODULE; use await import() instead

There's also a subtle difference in what you get back. ESM exports are live bindings, while a CommonJS object is a value copied at the moment of export:

// cnt.cjs: let count = 0; module.exports = { count, inc() { count++ } };
c.inc(); c.count;   // 0  (a copy of the number)

// cnt.mjs: export let count = 0; export function inc() { count++ }
inc(); count;       // 1  (live binding)

So in practice, for libraries I still ship both formats through the "exports" field in package.json, or ESM only, if all my consumers are on a modern Node.

Interviewers look for: knowing that require(esm) now works (except with top-level await), and live bindings versus copies.

IntermediateHow does require resolve a module name like "lodash" versus "./utils"?

So it depends on the string. There are three kinds, and Node resolves them differently:

  1. Built-ins, like "fs" or "node:fs". These always win. I prefer the node: prefix because it can't be shadowed by an npm package.
  2. Relative or absolute paths, like "./utils". In CommonJS, Node tries utils.js, .json, .node, then a utils/ folder with index.js. ESM does not guess extensions, so I have to write "./utils.js".
  3. Bare names, like "lodash". Node looks in ./node_modules, then the parent folder's node_modules, all the way up to the root. Inside the package it reads package.json: the "exports" field if it's there, otherwise "main".
// package.json of a library
{
  "name": "my-lib",
  "exports": {
    ".":       { "import": "./dist/index.js", "require": "./dist/index.cjs" },
    "./utils": "./dist/utils.js"
  }
}
// import "my-lib/internal/secret"  ->  ERR_PACKAGE_PATH_NOT_EXPORTED

The "exports" map is important because it's also encapsulation. Consumers can only import the paths I list there.

One gotcha I've hit is moving a TypeScript project to ESM: every relative import suddenly needed a .js extension, even in .ts source files, because that's the file that exists at runtime.

AdvancedWhat happens with circular dependencies in CommonJS and in ESM?

So both systems allow cycles without crashing, but in both you can end up seeing a value that isn't ready yet. They fail differently, though.

In CommonJS, when a.js requires b.js and b.js requires a.js back, b gets whatever a's module.exports holds at that moment, which is often an empty object:

// a.js
const b = require("./b");
module.exports = { name: "a", fromB: b.name };

// b.js
const a = require("./a");            // a is still {} here
module.exports = { name: "b", fromA: a.name };   // fromA: undefined
// Node warns: "Accessing non-existent property 'name' of module exports
// inside circular dependency"

In ESM, bindings are live, so reading them later works. But touching a let, const or class export before its module has run throws a ReferenceError ("Cannot access 'x' before initialization"), because of the temporal dead zone. Function declarations are hoisted, so those usually work.

In a real project this came up with a models/index.js barrel file that every model imported, and that itself imported every model. One model's extends BaseModel read undefined at load time.

How I fix cycles, in order of preference: pull the shared piece into a third module both can depend on, import from the specific file instead of a barrel, or move the require or import() inside the function that needs it, so it runs after loading is finished. madge --circular in CI catches new ones.

Interviewers look for: "partial exports" in CommonJS and the TDZ in ESM, plus barrel files as the usual cause.

6. Node.js Process Lifecycle

BasicWalk me through what happens when you run node app.js.

So at a high level: Node starts up, loads my modules, runs the top-level code once, and then sits in the event loop for as long as there is anything to wait for. When nothing is left, it exits.

In more detail, there are about six steps:

  1. The OS starts the process. Node reads flags and environment variables, for example --env-file=.env.
  2. V8 and libuv are initialised, including the event loop.
  3. Node loads app.js and its dependency graph. With ESM, all static imports are linked before any code runs.
  4. Top-level code runs, top to bottom. That's where I call app.listen() and open DB pools. Those calls register work; they don't wait for it.
  5. The microtask queues drain.
  6. The event loop runs lap after lap until there are no timers, open handles or pending requests left. Then it fires beforeExit and exit.
console.log("hi");                                 // exits right away
setTimeout(() => console.log("bye"), 2000);        // exits after 2s
http.createServer(handler).listen(3000);           // never exits by itself

What keeps a server alive is simply that the listening socket counts as an active handle. The loop always has something to wait for.

Interviewers look for: "top-level code once, then the loop, and exit when no handles are left". Connecting that to why a server keeps running is a plus.

BasicWhat do exit codes mean, and when would you call process.exit()?

So the exit code is a number the process reports to whatever started it. 0 means success, and anything else means failure. Docker, Kubernetes, CI and shell scripts all use it to decide whether to restart, retry or fail the build.

// A CLI script: set the code and let Node finish on its own
try {
  await migrate();
} catch (err) {
  console.error(err);
  process.exitCode = 1;   // preferred: lets pending output flush
}

// A crash handler: state is unknown, stop now
process.on("uncaughtException", (err) => {
  logger.fatal(err);
  process.exit(1);
});

What I try to avoid is calling process.exit() casually. It stops the process immediately. Pending writes to stdout when it's a pipe, in-flight HTTP responses and unflushed logs can all be lost. Setting process.exitCode and letting the loop empty naturally is safer.

So in practice I call process.exit() only in two places: after a graceful shutdown has finished, and in fatal-error handlers where carrying on would be worse than stopping.

IntermediateWhat's the difference between uncaughtException and unhandledRejection? Should you keep the process running after one?

So uncaughtException fires when a thrown error bubbles all the way up without a try/catch. unhandledRejection fires when a promise rejects and nothing ever attaches a .catch or awaits it inside a try. Since Node 15, an unhandled rejection crashes the process by default, the same as an uncaught exception.

Promise.reject(new Error("x"));
setTimeout(() => console.log("alive"), 100);
// Error: x ... process exits with code 1, "alive" never prints

Should I keep running? No. After an uncaught exception, the app is in an unknown state. A request may have half-updated something, a lock may still be held, or a connection may be left in a bad state. The Node docs say it plainly: using it to resume normal operation isn't safe.

process.on("uncaughtException", (err) => {
  logger.fatal({ err }, "uncaught exception");
  process.exit(1);   // let the orchestrator start a clean process
});
process.on("unhandledRejection", (reason) => {
  logger.fatal({ reason }, "unhandled rejection");
  shutdown(1);
});

What I rely on is having several replicas plus a supervisor like Kubernetes, systemd or PM2. One pod crashing and restarting is a non-event. One pod staying alive with corrupted state is a very long debugging session.

Interviewers look for: "log, then exit, and let the orchestrator restart it". Swallowing the error is the red flag.

IntermediateWhat's the difference between the beforeExit and exit events?

So beforeExit fires when the loop has run out of work and Node is about to exit on its own. I can still schedule async work there, and if I do, the process keeps going. exit fires when the process is definitely ending, for any reason, and only synchronous code runs there. Timers and promises scheduled inside it are dropped.

process.on("beforeExit", () => console.log("beforeExit"));
process.on("exit", (code) => {
  setTimeout(() => console.log("never"), 0);   // ignored
  console.log("exit", code);
});

// beforeExit
// exit 0

The other difference that matters is when they fire. beforeExit does not fire on process.exit(), on an uncaught exception, or on signals. exit fires on the first two. And a plain SIGTERM with no handler kills the process without firing either.

The trap I've seen is someone putting await db.end() in an exit handler. It looks fine in code review and does nothing at runtime.

So in practice I use exit only for a final synchronous log line. Real cleanup goes in the signal handlers.

AdvancedHow would you implement graceful shutdown for an Express API running in Kubernetes?

So the goal is that no in-flight request fails during a deploy. When Kubernetes wants to stop a pod, it sends SIGTERM, waits terminationGracePeriodSeconds (30 by default), and then sends SIGKILL, which can't be caught. Within that window I need to stop taking new work, finish the current work, close resources, and exit.

const server = app.listen(3000);
let shuttingDown = false;

app.get("/readyz", (req, res) =>
  res.status(shuttingDown ? 503 : 200).end());

process.on("SIGTERM", async () => {
  if (shuttingDown) return;
  shuttingDown = true;                            // 1. fail readiness
  setTimeout(() => process.exit(1), 25_000).unref();  // hard deadline

  await new Promise((r) => setTimeout(r, 5_000)); // 2. let the endpoints update
  server.close(async (err) => {                   // 3. stop accepting, drain in-flight
    await db.end();                               // 4. close pools and queues
    await redis.quit();
    process.exit(err ? 1 : 0);
  });
  server.closeIdleConnections();                  // 5. drop idle keep-alive sockets
});

What happens here: first I flip readiness to 503, so the load balancer stops sending new traffic. The short sleep covers the gap while Kubernetes removes the pod from the Service endpoints. Traffic can keep arriving for a few seconds after SIGTERM, and this is the step most people miss. Then server.close() stops accepting new connections and calls back once the active ones finish. Keep-alive sockets can hold that up, which is why I close idle ones. On Node 19 and later, close() already does that for me.

One gotcha: if the container starts through npm start or a shell script, the signal may never reach Node. I run node directly as PID 1, or use tini or --init.

Interviewers look for: readiness flip, then drain, then close resources, then a hard timeout. Mentioning the endpoint-propagation delay and the PID 1 issue marks real production experience.

AdvancedYour Node script finishes its work but never exits. How do you debug that?

So a Node process stays alive as long as there's an active handle: an open socket, a server, a timer, a DB pool with idle connections, a file watcher or a child process. If the script hangs at the end, something is still holding the loop open.

// Quick check: what is keeping the loop alive right now?
setTimeout(() => {
  console.log(process.getActiveResourcesInfo());
  // [ 'TCPSocketWrap', 'TCPSocketWrap', 'Timeout' ]
}, 5000).unref();

What that gives me is a list of resource types. Two TCPSocketWraps at the end of a migration script almost always means the Postgres pool or Redis client was never closed. For more detail, the why-is-node-running package prints stack traces showing where each handle was created.

The common culprits I've found are:

  1. DB pools or Redis clients never closed. The fix is await pool.end() in a finally.
  2. A setInterval for metrics or cache refresh. The fix is clearInterval, or .unref() if it should never keep the process alive.
  3. HTTP keep-alive agents holding sockets open.

The same issue shows up as Jest's "did not exit one second after the test run" warning, and the fix is the same: close resources in afterAll.

What I avoid is patching it with process.exit(0) at the end. It hides a resource leak, and it can cut off buffered output.

Part 2 — Node's Built-in Tools

7. Events & EventEmitter

BasicWhat is an EventEmitter, and where does Node use it?

So an EventEmitter is Node's built-in publish/subscribe object. One piece of code calls emit("name", data), and every function registered with on("name") runs. The emitter doesn't know who's listening, which keeps the two sides decoupled.

import { EventEmitter } from "node:events";

class OrderService extends EventEmitter {
  async create(data) {
    const order = await db.orders.insert(data);
    this.emit("created", order);
    return order;
  }
}

const orders = new OrderService();
orders.on("created", (o) => sendConfirmationEmail(o));
orders.on("created", (o) => analytics.track("order", o));

What I like about this is that create doesn't know emails or analytics exist. Adding "notify the warehouse" later is one more listener, with no change to the service.

And it's everywhere in Node's own APIs: http.Server emits "request", streams emit "data", "end" and "error", sockets emit "close", and process itself emits "SIGTERM" and "exit".

Interviewers look for: pub/sub, decoupling, and knowing that streams and servers are emitters underneath.

BasicAre EventEmitter listeners called synchronously or asynchronously?

So they're called synchronously, in the order they were registered, inside the emit() call. emit doesn't return until every listener has finished.

const e = new EventEmitter();
e.on("x", () => console.log("listener"));

console.log("before");
e.emit("x");
console.log("after");

// before
// listener
// after

That has two practical consequences. First, a slow listener blocks the emitter and every listener after it, and on the main thread that means it blocks the whole server. Second, if a listener throws, the error comes out of emit() at the call site, and the listeners after it don't run.

If a listener needs to do slow work, it should hand it off: start an async operation, or push to a queue. If I specifically want the listener to run later, I can wrap its body in setImmediate.

One thing people also assume is that emit waits for async listeners. It doesn't. It calls them, gets a promise back, and ignores it. emit just returns true if there were listeners and false if there weren't.

IntermediateWhat happens if you emit an "error" event and nobody is listening?

So "error" is special. If there's no listener for it, emit("error", err) throws the error. If nothing up the stack catches it, that's an uncaught exception and the process crashes.

const e = new EventEmitter();
try {
  e.emit("error", new Error("boom"));
} catch (err) {
  console.log("thrown:", err.message);   // thrown: boom
}

The dangerous version is when the emit happens asynchronously, for example a socket or stream erroring later. Then there's no try around it, and the server simply dies.

A real case: we had a Redis client created without an "error" listener. During a Redis failover the client emitted "error", and every API pod crashed at the same time, even though the client would have reconnected on its own.

redis.on("error", (err) => logger.warn({ err }, "redis error"));   // always

So in practice, any emitter-based thing I create, like DB clients, streams, sockets or child processes, gets an "error" listener. For streams I use pipeline(), which wires the error handling up for me.

Interviewers look for: "an unhandled error event throws and crashes the process", plus a real example like a client or a stream.

IntermediateWhat does the "MaxListenersExceededWarning: Possible EventEmitter memory leak" warning mean, and how do you fix it?

So Node warns when a single event on one emitter gets more than 10 listeners. Ten is the default limit. It's only a warning, and nothing breaks. But in my experience it's almost always a real leak: a listener being added over and over and never removed.

// The classic bug: a new listener on a shared emitter per request
app.get("/stream", (req, res) => {
  bus.on("update", (data) => res.write(data));   // never removed
});
// after 11 requests:
// MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
// 11 update listeners added to [EventEmitter]. MaxListeners is 10.

What happens here is that each request adds a closure holding its own res. When the client disconnects, the listener stays, so both the closure and the response object are never garbage-collected. Memory grows until the process runs out.

The fix is to remove the listener when the request ends:

app.get("/stream", (req, res) => {
  const onUpdate = (data) => res.write(data);
  bus.on("update", onUpdate);
  req.on("close", () => bus.off("update", onUpdate));
});

Only when I genuinely expect many listeners, like a pub/sub bus with many subscribers, do I raise the limit with setMaxListeners(n). Just silencing the warning without checking is how leaks reach production.

IntermediateHow do you wait for an event with async/await?

So Node has two helpers in node:events for exactly this. once(emitter, name) returns a promise for the next time that event fires. on(emitter, name) returns an async iterator of every time it fires.

import { EventEmitter, once, on } from "node:events";

const e = new EventEmitter();
setTimeout(() => e.emit("ready", 42), 10);

const [value] = await once(e, "ready");   // resolves with an array of the args
console.log(value);                       // 42

// Stream every event, with cancellation
const ac = new AbortController();
for await (const [msg] of on(socket, "message", { signal: ac.signal })) {
  handle(msg);
}

A detail I like: once also rejects if the emitter emits "error" before the event arrives. So I get normal try/catch error handling instead of wiring two listeners and remembering to clean up both.

A place I've used this is in startup code: await once(server, "listening") before running the smoke test, or await once(worker, "online"). It reads much more clearly than a callback.

So in practice, whenever I'd write a new Promise that wraps a single .once listener, I use events.once instead.

AdvancedWhat goes wrong when an EventEmitter listener is an async function? How do you handle it?

So emit calls the listener and discards its return value, so an async listener's promise is ignored. If that listener throws, the rejection isn't caught by emit or by any "error" handler. It becomes an unhandled rejection, and on modern Node that crashes the process.

orders.on("created", async (order) => {
  await sendEmail(order);   // if this rejects -> unhandledRejection -> crash
});
orders.emit("created", order);   // returns true immediately, doesn't await

There are two fixes. The simple one is to catch inside every async listener. The built-in one is captureRejections, which sends rejected promises from listeners to the "error" event:

const e = new EventEmitter({ captureRejections: true });
e.on("x", async () => { throw new Error("async fail"); });
e.on("error", (err) => console.log("caught:", err.message));
e.emit("x");
// caught: async fail

The bigger design point: EventEmitter is in-process and fire-and-forget. If the process crashes between saving the order and the email listener finishing, that email is lost, and nothing retries it.

So in practice I use EventEmitter for in-process decoupling where losing an event is acceptable. For anything that must happen, like emails, payments or webhooks, I write to a durable queue such as BullMQ, SQS or Kafka, ideally with an outbox table in the same transaction as the order.

Interviewers look for: "emit ignores promises", captureRejections, and knowing when to use a real queue instead.

8. The File System

BasicNode's fs module has sync, callback and promise APIs. Which do you use, and when?

So in application code I use the promise API, node:fs/promises, almost everywhere. It's non-blocking and it works with try/catch. I use the sync API only at startup or in CLI scripts. The callback API I really only see in older code.

import { readFile, writeFile, mkdir } from "node:fs/promises";
import { readFileSync } from "node:fs";

// Startup: fine to block, no traffic yet
const certs = { key: readFileSync("./tls/key.pem") };

// Request path: always async
app.post("/avatar", async (req, res) => {
  await mkdir("uploads", { recursive: true });
  await writeFile(`uploads/${id}.png`, req.body);
  res.status(201).end();
});

What I'm thinking about is simple: will a user be waiting while this runs? If yes, it has to be async, because a sync read in a route blocks every request, not just this one.

Two small things I always pass: an encoding like "utf8" when I want a string, because otherwise I get a Buffer, and { recursive: true } on mkdir, so it doesn't fail if the folder already exists.

BasicHow do you check whether a file exists before reading it?

So the honest answer is that I usually don't check first. I just try the operation and handle the error. Checking first creates a race condition: another process can delete or create the file between my check and my read.

// Avoid: check-then-act (TOCTOU race)
if (existsSync(path)) {
  const data = await readFile(path, "utf8");   // may still throw ENOENT
}

// Prefer: just try it
try {
  return await readFile(path, "utf8");
} catch (err) {
  if (err.code === "ENOENT") return null;      // not found is expected
  throw err;                                   // anything else is a real problem
}

The race has a name: TOCTOU, time-of-check to time-of-use. Beyond being a correctness issue, it can be a security issue too, because a file can be swapped for a symlink in that gap.

The same idea applies to writes. If I want "create only if it doesn't exist", I use the wx flag, which is atomic at the OS level:

await writeFile("lock", String(process.pid), { flag: "wx" });
// second time: throws with err.code === "EEXIST"

The error codes I know by heart are ENOENT (not found), EEXIST (already exists), EACCES/EPERM (permissions) and EISDIR (it's a directory).

Interviewers look for: "try and handle ENOENT" rather than exists, and the word race condition or TOCTOU.

IntermediateWhat is path traversal, and how do you prevent it when serving user-requested files?

So path traversal is when user input containing .. or an absolute path makes my code read or write outside the folder I intended. The classic payload is ../../etc/passwd.

// Vulnerable
app.get("/files/:name", (req, res) =>
  res.sendFile(path.join(UPLOADS, req.params.name)));

path.join("/srv/uploads", "../../etc/passwd");   // "/etc/passwd"
path.resolve("/srv/uploads", "/etc/passwd");     // "/etc/passwd"

What happens there is that join and resolve normalise the .. segments, so the result escapes the uploads folder. With resolve, an absolute path argument simply replaces everything before it.

My fix has two layers: resolve the final path, then check it's still inside the base directory.

const BASE = path.resolve("uploads");

function safePath(userInput) {
  const target = path.resolve(BASE, userInput);
  if (!target.startsWith(BASE + path.sep)) {
    throw Object.assign(new Error("Forbidden"), { status: 403 });
  }
  return target;
}

The + path.sep matters. Without it, /srv/uploads-private/x would pass a check for /srv/uploads.

Even better, I don't let users choose paths at all. I store uploads under a generated UUID and keep the original filename in the database. And in Express, res.sendFile(name, { root: BASE }) also refuses paths that escape root.

Interviewers look for: resolve, then prefix check with a separator, and ideally "don't use user input as a filename at all".

IntermediateHow would you serve or process a 2GB file in Node?

So I'd never load it into memory with readFile. That would try to allocate a 2GB buffer, which is slow, can exceed Buffer size limits on some setups, and would take most of the heap for one request. I stream it instead, so memory use stays at a few chunks of about 64KB no matter how big the file is.

import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";

// Serve a large file
app.get("/export", async (req, res, next) => {
  res.setHeader("Content-Type", "text/csv");
  try {
    await pipeline(createReadStream("export.csv"), res);
  } catch (err) { next(err); }
});

// Compress a large file on disk
await pipeline(
  createReadStream("huge.log"),
  createGzip(),
  createWriteStream("huge.log.gz"),
);

The reason I use pipeline instead of .pipe() is that it handles backpressure and errors, and it destroys every stream in the chain if one fails or the client disconnects. With plain .pipe(), a client going away mid-download can leave a file descriptor open.

For line-by-line processing, like a large log or CSV, I'd use readline with for await over a read stream.

So in practice: readFile for config-sized files, streams for anything that could be large or comes from a user.

AdvancedHow do you write a file safely so a crash never leaves it half-written?

So writeFile isn't atomic. It truncates the file and then writes. If the process crashes or the disk fills halfway through, I'm left with a corrupted or empty file. The standard fix is to write to a temporary file in the same directory and then rename it over the original, because rename is atomic on POSIX file systems.

import { open, rename } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";

async function writeFileAtomic(file, data) {
  const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${randomUUID()}.tmp`);
  const fh = await open(tmp, "w");
  try {
    await fh.writeFile(data);
    await fh.sync();          // flush to disk, not just the OS cache
  } finally {
    await fh.close();
  }
  await rename(tmp, file);    // readers see the old file or the new one, never half
}

What happens here: readers either see the complete old version or the complete new version. The temp file has to be in the same directory, because rename across file systems isn't atomic. It turns into a copy, or fails with EXDEV. The fsync makes sure the data is actually on disk before the rename is.

The other half of the problem is concurrent writers in the same process. Two requests updating the same JSON file will overwrite each other. I'd serialise them through a promise queue, or more realistically I'd stop using a file as a database.

So in practice this matters for local state like CLI config, caches or a lock file. For anything shared between instances, a real database or object storage is the right answer, because containers usually have ephemeral disks anyway.

Interviewers look for: temp file plus rename in the same directory, fsync, and knowing that local files aren't shared state in a scaled deployment.

9. Streams & Buffers

BasicWhat is a stream in Node, and why would you use one instead of just reading the whole file?

So a stream lets me process data in chunks as it arrives, instead of loading the whole thing into memory first. The main reason is memory: with a stream, memory use stays flat no matter how big the data is.

Let me give you an example. Say I have an endpoint that serves a 2GB video export.

// Loads all 2GB into memory for every request
app.get("/export", async (req, res) => {
  const data = await fs.promises.readFile("export.mp4");
  res.send(data);
});

// Streams ~64KB at a time
import { pipeline } from "node:stream/promises";
app.get("/export", async (req, res) => {
  res.type("video/mp4");
  await pipeline(fs.createReadStream("export.mp4"), res);
});

With the first version, ten people downloading at once means 20GB of memory, and the process gets killed. With the second, each request only holds a small chunk at any moment. There's also a latency win: the first bytes reach the client straight away instead of after the whole file is read.

So in practice I reach for streams whenever the size isn't small and known: file uploads and downloads, big CSV exports, proxying, and log processing. For a 2KB config file, readFile is simpler and totally fine.

Interviewers look for: memory staying flat as the reason, plus knowing when not to bother with streams.

BasicWhat are the four types of streams? Give me a real example of each.

So there are Readable, Writable, Duplex and Transform streams. The easiest way I remember them is by which direction the data goes.

TypeWhat it doesReal example
ReadableI read from itfs.createReadStream, the incoming req in an HTTP server
WritableI write to itfs.createWriteStream, the res object
DuplexRead and write, two separate channelsA TCP socket from net
TransformA Duplex where the output is computed from the inputzlib.createGzip(), crypto ciphers

The distinction people mix up is Duplex versus Transform. With a socket, what I write goes to the other machine and what I read comes from it. The two sides aren't related. With gzip, what comes out is literally my input, compressed.

Writing my own Transform is short:

import { Transform } from "node:stream";

const upper = new Transform({
  transform(chunk, encoding, callback) {
    callback(null, chunk.toString().toUpperCase());
  },
});

process.stdin.pipe(upper).pipe(process.stdout);

Interesting detail here: req and res in Express are just Node streams underneath, which is why body parsing exists at all.

IntermediateWhat is backpressure, and what happens if you ignore it?

So backpressure is the signal a slow destination sends back to a fast source saying "stop, I can't keep up". If I ignore it, the chunks that haven't been written yet pile up in memory, and I've basically lost the whole benefit of streaming.

Let me give you an example of ignoring it by hand:

// Wrong: ignores the return value of write()
readable.on("data", chunk => {
  writable.write(chunk);
});

// What backpressure looks like done manually
readable.on("data", chunk => {
  const ok = writable.write(chunk);
  if (!ok) {
    readable.pause();
    writable.once("drain", () => readable.resume());
  }
});

What happens here is that write() returns false once the writable's internal buffer is past its highWaterMark. The polite thing is to pause the source until the "drain" event says the buffer has emptied. In the first version, reading from a fast disk while sending to a slow mobile client would keep buffering the whole file in memory.

In practice I never write that manual version. pipeline() and .pipe() both handle backpressure for me. I only think about it explicitly when I'm calling write() myself in a loop, for example generating a big CSV row by row. Then I await once(stream, "drain") when write returns false.

Interviewers look for: the write() return value and the drain event, not just the word "backpressure".

IntermediateWhy should you use pipeline() instead of chaining .pipe()?

So the short answer is error handling and cleanup. .pipe() doesn't forward errors down the chain, and if one stream fails, the others don't get destroyed. pipeline() destroys every stream in the chain and gives me one error to handle.

Let me show the difference:

// .pipe(): each stream needs its own error handler,
// and a failure in the middle leaves the file handle open
fs.createReadStream("big.log")
  .pipe(zlib.createGzip())
  .pipe(fs.createWriteStream("big.log.gz"));

// pipeline(): one place for errors, everything cleaned up
import { pipeline } from "node:stream/promises";

try {
  await pipeline(
    fs.createReadStream("big.log"),
    zlib.createGzip(),
    fs.createWriteStream("big.log.gz"),
  );
} catch (err) {
  logger.error({ err }, "compression failed");
}

One gotcha I've hit with .pipe() is a file download where the client disconnected halfway through. The read stream stayed open because nothing told it to stop. Under load, we leaked file descriptors until the process hit its limit and started failing with EMFILE. Switching to pipeline fixed it, because when res closes early, it destroys the source too.

So in practice I always use the promise version from node:stream/promises. It also lets me pass an async generator as a transform step, which is often cleaner than writing a Transform class.

IntermediateWhat is a Buffer, and why does "café".length differ from its byte length?

So a Buffer is a fixed-size chunk of raw bytes. It's Node's way of handling binary data, and it's actually a subclass of Uint8Array. Strings in JavaScript are characters. Buffers are bytes. Those two aren't the same count once you leave plain ASCII.

"café".length;                    // 4 characters
Buffer.from("café");              // <Buffer 63 61 66 c3 a9>
Buffer.byteLength("café");        // 5 bytes

Buffer.from("hello").toString("base64");   // "aGVsbG8="

What happens here is that é takes two bytes in UTF-8, c3 a9. Emoji and a lot of non-Latin scripts take three or four bytes each.

This matters in real code more than people expect. In my last project we had a 64KB limit on a user bio field, and the check used string.length. Someone wrote in Japanese and got past the check while being well over the limit in bytes, which the database then rejected. For anything about size (upload limits, database column limits, HTTP Content-Length) I use Buffer.byteLength.

One more thing I'd mention: Buffer.alloc(n) zero-fills, while Buffer.allocUnsafe(n) is faster but can contain old memory. I only use the unsafe one when I'm about to overwrite every byte.

AdvancedYou're concatenating stream chunks as strings and some characters come out garbled. What's going on?

So the bug is that a multi-byte character got split across two chunks. Chunks are cut at byte boundaries, not character boundaries. When I call toString() on each chunk separately, each half is invalid UTF-8 on its own.

Let me reproduce it. The euro sign is three bytes, e2 82 ac:

const a = Buffer.from([0xe2, 0x82]);   // first two bytes
const b = Buffer.from([0xac]);         // last byte

a.toString() + b.toString();           // two U+FFFD replacement chars, garbled
Buffer.concat([a, b]).toString();      // "€"

This is exactly the bug in the classic hand-rolled body parser, body += chunk, because += converts each chunk to a string immediately.

There are three fixes I'd reach for:

// 1. Collect bytes, decode once at the end
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const body = Buffer.concat(chunks).toString("utf8");

// 2. Let the stream decode safely
req.setEncoding("utf8");   // uses a StringDecoder internally

// 3. Built-in helper
import { text } from "node:stream/consumers";
const body2 = await text(req);

Options two and three use a decoder that holds onto an incomplete trailing sequence until the next chunk arrives. It's a nice question because it only shows up with non-ASCII data and unlucky chunk sizes, so it passes every test with English input.

Interviewers look for: "chunks split on bytes, not characters", and Buffer.concat before decoding.

AdvancedDesign an endpoint that lets users download a 5-million-row report as CSV without blowing up memory.

So the idea is to stream end to end: a database cursor on one side, a transform that turns rows into CSV lines in the middle, and the HTTP response on the other side, all tied together with pipeline so backpressure and cleanup just work.

import { pipeline } from "node:stream/promises";

app.get("/reports/orders.csv", requireAuth, async (req, res) => {
  res.setHeader("Content-Type", "text/csv");
  res.setHeader("Content-Disposition", 'attachment; filename="orders.csv"');

  const rows = db.query(cursorSql, [req.user.orgId]); // a Readable of rows

  await pipeline(
    rows,
    async function* (source) {
      yield "id,total,created_at\n";
      for await (const row of source) {
        yield `${row.id},${row.total},${row.created_at.toISOString()}\n`;
      }
    },
    res,
  );
});

What happens here is that if the client is on a slow connection, res fills up, the generator stops pulling, and the cursor stops fetching rows from Postgres. So memory stays at a few chunks the whole time. If the user cancels the download, pipeline rejects and destroys the cursor, so we don't keep a database connection busy for nothing.

A few things I'd add in a real system. I'd escape CSV fields properly, with a library like csv-stringify, since commas and quotes in data break naive output. I'd also set a query timeout. And past a certain size, I'd move it to a background job that writes to S3 and emails a signed link, because holding a request open for ten minutes doesn't sit well with load balancer timeouts.

10. Paths, URLs & Process

BasicHow do you manage configuration and secrets in a Node app?

So I keep all configuration in environment variables, read them in one config module at startup, and fail loudly if anything required is missing. The code stays the same across laptop, staging and production. Only the environment changes.

// config.js
process.loadEnvFile?.();          // or run with: node --env-file=.env server.js

const required = ["DATABASE_URL", "JWT_SECRET"];
for (const key of required) {
  if (!process.env[key]) throw new Error(`Missing env var: ${key}`);
}

export const config = {
  port: Number(process.env.PORT ?? 3000),
  databaseUrl: process.env.DATABASE_URL,
  jwtSecret: process.env.JWT_SECRET,
  isProd: process.env.NODE_ENV === "production",
};

A few things I'm deliberate about. The .env file is gitignored and I commit a .env.example with dummy values instead. Everything in process.env is a string, so I convert numbers and booleans in one place. "false" is truthy, which has bitten people. And the rest of the app imports config rather than reaching into process.env all over the place, so there's a single list of what the app needs.

In production the secrets come from the platform's secret manager, not from a file. Recent Node versions can load .env natively with --env-file, so I often don't need the dotenv package anymore.

Interviewers look for: validating at startup, never committing secrets, and knowing env values are always strings.

BasicWhat's the difference between path.join and path.resolve?

So path.join just glues segments together and normalises the result. path.resolve works from right to left to build an absolute path, and it restarts whenever it hits a segment that's already absolute.

path.join("/a", "/b");         // "/a/b"
path.resolve("/a", "/b");      // "/b"   (the absolute "/b" wins)

path.join("uploads", "x.png");      // "uploads/x.png"   (relative)
path.resolve("uploads", "x.png");   // "/current/dir/uploads/x.png"

The practical difference is that resolve falls back to process.cwd(), which is wherever the process was started from, not where the file lives. That's a common bug: the app works when I run node server.js from the project root and then can't find its templates when a process manager starts it from somewhere else.

So for files that ship with my code, I anchor on the module's own location:

// ES modules (Node 20.11+)
const templates = path.join(import.meta.dirname, "templates");

And I always use the path module rather than string concatenation with "/", so it's correct on Windows too.

IntermediateA user sends ../../etc/passwd as a filename. How do you stop path traversal?

So the point is that path.join doesn't protect me at all. It happily walks up with ... What I do is resolve the full path and then check it still sits inside the folder I meant to serve from.

path.join("/app/uploads", "../../etc/passwd");   // "/etc/passwd"

const ROOT = path.resolve("/app/uploads");

function safePath(userInput) {
  const full = path.resolve(ROOT, userInput);
  if (!full.startsWith(ROOT + path.sep)) {
    throw new HttpError(400, "Invalid path");
  }
  return full;
}

The + path.sep part matters. Without it, a sibling folder like /app/uploads-private would pass a plain startsWith("/app/uploads") check.

Honestly though, the better design is to not use user input as a path at all. In my last project, uploaded files were stored under a generated UUID, and the database mapped that ID to the original filename. The download route looked up the ID and never touched the user's string. If I just need to serve static files, express.static or res.sendFile with the root option already handles traversal for me.

Interviewers look for: knowing that join doesn't sanitise, and the "don't use user input as a path" design answer.

IntermediateHow do you implement graceful shutdown, and why does it matter?

So when a platform like Kubernetes or ECS wants to stop my container, it sends SIGTERM first and waits a grace period before sending SIGKILL. Graceful shutdown means using that window to stop taking new work, finish what's in flight, and close connections cleanly.

const server = app.listen(config.port);
let shuttingDown = false;

async function shutdown(signal) {
  if (shuttingDown) return;
  shuttingDown = true;
  logger.info({ signal }, "shutting down");

  const force = setTimeout(() => process.exit(1), 10_000);
  force.unref();

  server.close();                   // stop accepting new connections
  server.closeIdleConnections();    // drop idle keep-alive sockets
  await queue.close();              // stop pulling jobs
  await db.end();                   // close the pool
  process.exit(0);
}

process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

A few details matter here. server.close() stops new connections but waits for open ones, and keep-alive connections can keep it waiting forever, which is why I close idle ones. The forced timeout makes sure I exit before the platform's SIGKILL does it for me. And I usually make the readiness check start failing first, so the load balancer stops sending traffic.

Without this, every deploy cuts off requests mid-flight. Users see 502s, and a request that was halfway through a multi-step write can leave data inconsistent.

One gotcha: if the container runs npm start, npm may not pass the signal on to Node. I run node server.js directly in the Dockerfile's CMD.

IntermediateWhat should you do in an uncaughtException or unhandledRejection handler?

So the short answer is: log it, and then exit. I don't try to keep serving. After an uncaught exception, the process is in an unknown state. A transaction might be half done or a lock might never be released. So the safe move is to crash and let the process manager restart a clean copy.

process.on("uncaughtException", (err) => {
  logger.fatal({ err }, "uncaught exception");
  process.exit(1);
});

process.on("unhandledRejection", (reason) => {
  logger.fatal({ err: reason }, "unhandled rejection");
  process.exit(1);
});

Since Node 15, an unhandled rejection already crashes the process by default. So the handler's real job is to make sure the error is logged properly (with the stack, in my structured logger, flushed to wherever logs go) before the process dies.

The mistake I've seen in real codebases is a handler that just does console.log(err) and carries on. The server looks healthy to the load balancer but has, say, a broken database pool, so every request fails for hours. Crashing is better, because a crash is visible and gets restarted.

So in practice these handlers are a last-resort safety net. The actual fix is catching errors where they happen, and in Express 5, letting async handler errors reach the error middleware.

Interviewers look for: "log and exit, let the supervisor restart", not "catch and continue".

AdvancedWhy does it matter what exit code your process uses, and when does process.exit() cause problems?

So the exit code is how my process tells the outside world whether it succeeded. Zero means success, anything else means failure. Orchestrators, CI and shell scripts make decisions based on it, so exiting with the wrong code gives them the wrong picture.

Let me give you an example from CI. We had a migration script that caught errors, logged them, and then fell off the end, which exits with 0. The pipeline treated a failed migration as a success and went ahead with the deploy.

// Wrong: logs the failure but exits 0
try { await migrate(); } catch (err) { console.error(err); }

// Right
try {
  await migrate();
} catch (err) {
  console.error(err);
  process.exitCode = 1;   // let the event loop drain, then exit with 1
}

That's the other half of the question. process.exit() kills the process immediately, even if there are pending async writes. So with a logger that writes asynchronously, like pino with a transport, calling process.exit(1) right after logger.error can drop the very log line that explains the crash. It also truncates stdout output that's piped to a file.

So in scripts I prefer setting process.exitCode and letting Node exit naturally once there's nothing left to do. In a server I do call exit explicitly during shutdown, but only after I've awaited the flush and closed everything that matters.

11. An HTTP Server Without Express

BasicCan you build an API in Node without Express? What would you have to handle yourself?

So yes. Express is built on Node's own http module, and I can use that directly. The cost is that everything a framework does, I write by hand.

import http from "node:http";

const server = http.createServer((req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);

  if (req.method === "GET" && url.pathname === "/health") {
    res.writeHead(200, { "Content-Type": "application/json" });
    return res.end(JSON.stringify({ ok: true }));
  }

  res.writeHead(404).end();
});

server.listen(3000);

What's missing is quite a list:

  • routing, including URL parameters like /users/:id,
  • query parsing (I get a raw string and have to build a URL myself),
  • body parsing, because the body arrives as a stream of chunks,
  • content-type headers, status helpers, and a central place for errors,
  • middleware for things like auth and logging.

I'd actually write it this way for something tiny, like a health-check sidecar or a webhook receiver with one route, where adding a dependency isn't worth it. For a real API with twenty routes, the if chain becomes unmanageable fast, and I'd pick Express or Fastify.

Interviewers look for: knowing Express is a thin layer over http, and being able to list concretely what it adds.

IntermediateHow do you read a JSON request body with the raw http module? What can go wrong?

So the request is a Readable stream, so I collect the chunks, join them as bytes, decode once, and then parse. The things that go wrong are bad JSON, huge bodies, and the wrong content type, and I have to guard against all three myself.

async function readJson(req, limit = 100_000) {
  if (!req.headers["content-type"]?.startsWith("application/json")) {
    throw new HttpError(415, "Expected JSON");
  }

  const chunks = [];
  let size = 0;
  for await (const chunk of req) {
    size += chunk.length;
    if (size > limit) throw new HttpError(413, "Body too large");
    chunks.push(chunk);
  }

  try {
    return JSON.parse(Buffer.concat(chunks).toString("utf8"));
  } catch {
    throw new HttpError(400, "Invalid JSON");
  }
}

Walking through it: the size check is the important security part. Without it, anyone can POST a gigabyte and make me buffer it all in memory. Collecting Buffers and decoding once avoids the split multi-byte character bug you get with body += chunk. And JSON.parse throws synchronously on bad input, so if it's inside a "data" event callback with no try/catch, one malformed request crashes the whole server.

That's essentially what express.json() does for me: it checks the content type, applies a size limit (100kb by default), and turns parse failures into a 400 error.

IntermediateWhat exactly are req and res in Node's HTTP server?

So req is an http.IncomingMessage, which is a Readable stream, and res is an http.ServerResponse, which is a Writable stream. Express doesn't replace them. It adds methods on top of these same objects.

Seeing them as streams explains a lot of behaviour:

// res is Writable: headers go first, then the body in pieces
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain");
res.write("part one\n");
res.write("part two\n");
res.end("done\n");

// So streaming a file is just piping into it
fs.createReadStream("big.txt").pipe(res);

The famous "Cannot set headers after they are sent" error makes sense here. Headers go out on the wire before the body, so once I've called write or end, the status and headers are already on their way and can't change. res.headersSent tells me whether that's happened.

On the req side, because it's a stream, the body isn't there when the handler runs. I have to consume it. And if I never read it, the data just sits in the socket, which is why body-parsing middleware exists.

So when I'm debugging something odd in Express, like a response that never finishes, I think about the underlying Writable: did anyone ever call end()?

IntermediateWhat's HTTP keep-alive, and what server timeouts would you configure?

So keep-alive means the TCP connection stays open after a response, so the client can send the next request over it without a new handshake. Node's server does this by default. The timeouts decide how long I'm willing to wait on slow or idle clients.

const server = app.listen(3000);

server.keepAliveTimeout = 65_000;   // idle time before closing a kept-alive socket
server.headersTimeout = 66_000;     // must be greater than keepAliveTimeout
server.requestTimeout = 30_000;     // max time to receive a full request

The one that's bitten me is keepAliveTimeout behind an AWS load balancer. Node's default is 5 seconds, but the ALB keeps idle connections for 60 seconds. So Node would close a socket just as the load balancer tried to reuse it, and we'd get random 502s, maybe one in a few thousand requests, with nothing in our logs. The fix is making Node's keep-alive timeout longer than the load balancer's, which is what the numbers above do.

requestTimeout and headersTimeout protect against Slowloris-style attacks, where a client sends headers one byte at a time to tie up connections. Newer Node versions have sensible defaults for these, but I still set them deliberately.

Interviewers look for: the load balancer keep-alive mismatch. It's a very real production bug.

AdvancedIf Express is built on http, why are Fastify or raw http so much faster?

So the difference is overhead per request. Raw http does the minimum. Express adds a layer on every request, and Fastify is designed specifically to keep that layer thin.

The concrete sources of overhead in Express are:

  • routing checks routes in a list, one by one, while Fastify uses a radix tree router (find-my-way) that jumps straight to the match,
  • every middleware is a function call in a chain, even for requests it doesn't care about,
  • res.json uses plain JSON.stringify, while Fastify compiles a faster serializer from the response schema,
  • Express adds many properties and helpers to req and res.

In benchmarks, a hello-world route in Fastify handles something like two to three times the requests per second of Express. But I'd be careful drawing conclusions from that. In my last project, a typical request spent about 2ms in the framework and 40ms waiting on Postgres and another service. Switching frameworks would have saved maybe 5% of the total latency. Adding an index saved far more.

So in practice I'd pick Fastify for a new high-throughput service, like a gateway or something with very thin handlers, where framework overhead is actually a meaningful fraction. For a normal CRUD API, Express's ecosystem and team familiarity usually matter more than its raw speed.

Interviewers look for: knowing why it's faster, and then putting framework overhead in proportion to database and network time.

12. npm & package.json

BasicWhat's the difference between dependencies and devDependencies?

So dependencies are packages the app needs at runtime in production, like Express or the database driver. devDependencies are only needed while developing, building or testing, like Vitest, ESLint or TypeScript.

npm install express        # goes into "dependencies"
npm install -D vitest      # goes into "devDependencies"

# In the production image, skip dev packages
npm ci --omit=dev

The reason it matters is the production install. With --omit=dev, the image is smaller, it builds faster, and there's less attack surface, because a vulnerable test tool isn't sitting on the production server.

One gotcha I've hit: someone put a package that was imported at runtime into devDependencies. Everything worked locally and in CI, because both install dev packages. It only crashed in production with "Cannot find module". So when I review a PR that adds a package, I check which section it went in.

For TypeScript projects there's a nuance. TypeScript itself is a devDependency since it only runs at build time, but if the Docker build compiles the code, it needs the dev packages in the build stage. That's where a multi-stage Dockerfile helps: build with everything, then copy only the output and production packages into the final image.

BasicWhat do ^ and ~ mean in a version number?

So versions follow semver, MAJOR.MINOR.PATCH. The caret allows minor and patch updates, the tilde only allows patch updates, and no symbol pins the exact version.

WrittenAcceptsRejects
^5.1.05.1.0 up to anything below 6.0.06.0.0
~5.1.05.1.x5.2.0
5.1.0exactly 5.1.0everything else
^0.3.20.3.x only0.4.0

That last row is the one people miss. Below 1.0, the caret is stricter, because in 0.x versions a minor bump is allowed to break things.

The honest caveat is that semver is a promise, not a guarantee. I've had a "minor" update of a library change a default and break our date formatting. That's why the range in package.json isn't what really protects me. The lockfile is, because it pins the exact version that was actually tested. So the caret mostly affects what happens when someone runs npm update, not what gets deployed.

IntermediateWhat is package-lock.json for, and what's the difference between npm install and npm ci?

So the lockfile records the exact version of every package in the tree, including the dependencies of my dependencies, plus integrity hashes. It's what makes installs reproducible, so I commit it.

The two commands treat it differently:

npm installnpm ci
LockfileMay update itNever changes it
Mismatch with package.jsonResolves and rewritesFails with an error
node_modulesUpdates in placeDeletes and installs fresh
Use itLocally, when adding packagesCI and Docker builds

Let me give you the scenario this prevents. Without a lockfile, express: "^5.1.0" can install 5.1.0 on my laptop and a newer version in CI two weeks later. And the bigger risk isn't Express, it's some transitive dependency four levels deep that shipped a bad patch overnight. With npm ci, CI installs exactly what I tested.

Also, npm ci failing on a mismatch is a feature. If someone edits package.json but forgets to commit the updated lockfile, the build fails immediately instead of quietly installing something nobody tested.

Interviewers look for: "commit the lockfile, use npm ci in CI", and that the lockfile covers transitive dependencies.

IntermediateHow do you handle security vulnerabilities in your dependencies?

So I treat it as an ongoing process, not a one-off npm audit fix. There are really three parts: finding out about issues, judging whether they actually affect us, and keeping updates small and frequent so fixes are easy to apply.

npm audit                    # list known vulnerabilities
npm audit --omit=dev         # only what ships to production
npm audit fix                # apply compatible (non-breaking) fixes
npm outdated                 # what's behind

In my last team we had Dependabot (Renovate works too) open grouped PRs weekly, and CI ran the audit on every build, failing on high or critical issues in production dependencies.

The judgement part matters because audit output is noisy. A "high" ReDoS in a dev-only build tool that never sees user input isn't the same as one in the JSON parser handling requests. When a fix isn't available upstream, I use overrides in package.json to force a patched version of a transitive dependency:

"overrides": {
  "some-nested-lib": "^2.4.1"
}

I'm also careful about npm audit fix --force. It will jump major versions to make the warning go away, which can break the app.

And given the supply-chain attacks of the last few years, where popular packages were hijacked and published with malicious code, I care about what we add in the first place. I check maintenance and download history, prefer fewer dependencies, and avoid running install scripts from unknown packages.

IntermediateWhat does "type": "module" in package.json do?

So it tells Node to treat .js files in that package as ES modules, meaning import and export, instead of CommonJS with require. Without it, .js defaults to CommonJS.

// package.json: { "type": "module" }
import express from "express";
import { config } from "./config.js";   // extension is required in ESM

export const app = express();

I can always override it per file: .mjs is always ESM, and .cjs is always CommonJS, whatever the package says.

The things that change when I switch are small but real. Relative imports need the file extension. __dirname and __filename don't exist, so I use import.meta.dirname and import.meta.filename. I get top-level await, which is nice for startup code like connecting to the database.

For a new project in 2025 onwards I default to ESM. The historic pain point, CommonJS code not being able to require an ESM package, is mostly gone too: current Node LTS versions support require() of synchronous ES modules. For a library I publish, I'd use the exports field to define entry points clearly.

AdvancedWhat's a peer dependency, and what problem does it solve?

So a peer dependency says "I work with this package, but I expect the app to install it, and I'll share the app's copy." It's for plugins, where two copies of the host library would break things.

Let me give you an example. Say I'm publishing an Express middleware package:

// my-auth-middleware/package.json
{
  "name": "my-auth-middleware",
  "peerDependencies": {
    "express": "^4.21.0 || ^5.0.0"
  },
  "devDependencies": {
    "express": "^5.1.0"
  }
}

If I put Express in dependencies instead, npm could install a second, nested Express just for my package. With React it's even worse: two copies means two separate contexts, and hooks break with the "invalid hook call" error. With a peer dependency, there's only ever the app's one copy, and npm warns (or since npm 7, errors) when the version doesn't fit the range.

That's where the ERESOLVE errors people hit come from. Reaching for --legacy-peer-deps makes the error go away but just skips the check, so I only use it when I've confirmed the version really does work, and I note why.

So as a rule: in a library, the framework it plugs into is a peer dependency, with a wide range, plus a devDependency so my own tests run.

Part 3 — Express

13. Why Express

BasicWhat is Express, and why would you use it instead of the plain http module?

So Express is a small web framework on top of Node's http module. It gives me routing, middleware, and request and response helpers, and otherwise stays out of the way.

Let me give you an example. Here's the same route written both ways:

// plain http
if (req.method === "GET" && req.url.startsWith("/users/")) {
  const id = req.url.split("/")[2];
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ id }));
}

// Express
app.get("/users/:id", (req, res) => {
  res.json({ id: req.params.id });
});

What Express gave me there is route matching with parameters, res.json setting the header and serialising, and a proper 404 for anything that doesn't match. Add express.json() and I also get body parsing with a size limit.

The bigger reason, honestly, is the ecosystem. Almost every Node package for auth, logging, rate limiting, uploads or security headers ships as Express-compatible middleware, and most Node developers already know it. That makes onboarding easy.

The trade-off is that Express is unopinionated, so it doesn't tell me how to structure the project. That freedom means I have to bring conventions for folders, validation and error handling myself.

Interviewers look for: routing, middleware, helpers, ecosystem, and awareness that "unopinionated" cuts both ways.

BasicWhat does "unopinionated" mean for Express, and is that good or bad?

So it means Express doesn't decide the project layout, the validation library, the ORM, or how errors are shaped. It gives me the routing and middleware mechanism and leaves everything else to me.

It's good and bad. For a small service or a team that already has strong conventions, it's great: nothing to fight, nothing unused. But I've joined a company where three Express services were written by three different teams, and each one had a different folder structure, a different error format, and a different way of doing auth. Moving between them felt like switching frameworks.

So in practice, when I start an Express project, I decide those opinions up front and write them down:

src/
  routes/        # URL to controller mapping, nothing else
  controllers/   # read req, call service, send res
  services/      # business logic, no req/res here
  middleware/    # auth, validation, errorHandler
  config.js

I also pick one validation library (like Zod) and one error format, and share them across services, sometimes as an internal package.

If a team wants those decisions made for them, that's the argument for NestJS. It's opinionated like Angular, with modules, dependency injection and decorators, and on a large team with a lot of turnover, that consistency can be worth the heavier learning curve.

IntermediateWhat changed in Express 5, and what do you need to watch for when upgrading from 4?

So the headline change is that Express 5 catches rejected promises from async handlers and middleware and sends them to the error handler. In Express 4, a throw inside an async handler became an unhandled rejection. Express 5 is now the default on npm, so new projects get it.

// Express 5: this reaches the error middleware automatically
app.get("/users/:id", async (req, res) => {
  const user = await db.users.find(req.params.id);   // if this throws...
  res.json(user);
});

app.use((err, req, res, next) => {                   // ...it lands here
  res.status(err.status ?? 500).json({ error: "Something went wrong" });
});

The upgrade gotchas I'd check for are mostly about routing and removed APIs:

  • Path syntax changed with the new path-to-regexp. A bare * wildcard has to be named, so /files/* becomes /files/*splat, and optional parts use braces, like /users{/:id}. Old patterns throw at startup, which at least makes them easy to find.
  • Old method signatures are gone, like res.send(status, body). It's res.status(404).send(...) now.
  • req.query is a read-only getter, so middleware that assigned a sanitised query back onto it breaks.
  • req.body is undefined rather than {} when no body parser ran.
  • It requires Node 18 or newer.

When we upgraded, the routing syntax and one old sanitising middleware were the only real work. Then we could delete the asyncHandler wrappers everywhere, which was the nice part.

Interviewers look for: async error handling as the main reason, plus at least one concrete migration gotcha.

IntermediateIn Express 4, how do you handle errors from async route handlers?

So in Express 4, the router only catches errors thrown synchronously. An async function returns a promise, and Express 4 ignores it. So a rejection never reaches the error middleware. The request hangs, and the process either logs an unhandled rejection or crashes.

The standard fix is a small wrapper that catches the rejection and passes it to next:

const asyncHandler = fn => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get("/users/:id", asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new NotFoundError("User");
  res.json(user);
}));

What happens here is that the wrapper calls the handler, and if the promise rejects, .catch(next) calls next(err), which is exactly the path to the error handler. Promise.resolve handles the case where the function isn't actually async.

The alternatives are express-async-errors, which patches the router so you don't need to wrap each handler, or a try/catch in every handler, which is repetitive and easy to forget. The real problem with the wrapper is also forgetting it. One missed route and that endpoint hangs on errors.

So in practice, if I'm on Express 4, I'd push to upgrade to 5, where this is built in. Until then, I'd use the wrapper plus a lint rule that flags async handlers without it.

AdvancedWhen would you choose Fastify, NestJS or Hono over Express for a new service?

So I'd decide based on what the service needs and who's going to maintain it. Raw performance is rarely the deciding factor on its own.

I'd pickWhen
ExpressStandard API, team already knows it, need a specific middleware from the ecosystem
FastifyHigh throughput or thin handlers, want schema validation and serialisation built in, good TypeScript support
NestJSBig team or many services, want enforced structure, dependency injection, and consistent patterns
HonoDeploying to edge runtimes like Cloudflare Workers, or want one codebase that runs on Node, Bun and Deno

Let me give you an example. In my last company, we had an internal gateway that did auth checks and forwarded requests, around 5,000 requests per second, with almost no logic per request. That's where Fastify made sense, because framework overhead was a big part of the total time. Meanwhile, our main product API was Express, because its time was spent in Postgres and it relied on a lot of existing middleware.

For NestJS, the case is organisational more than technical. With 30 engineers across several services, having modules, providers and guards laid out the same way everywhere saves a lot of review time.

So my honest default for a typical team is still Express 5 or Fastify, and I'd only move away from what the team knows when there's a concrete reason like throughput, runtime or team scale.

Interviewers look for: a reasoned trade-off tied to the team and workload, not "X is fastest".

14. Routing

BasicWhat's the difference between req.params, req.query and req.body?

So they're three different places the client can put data. params come from named segments in the path, query comes from the query string after the ?, and body is the request payload, which only exists after a body parser has run.

// PATCH /users/42?notify=true
// body: { "name": "Ana" }

app.use(express.json());

app.patch("/users/:id", (req, res) => {
  req.params.id;       // "42"
  req.query.notify;    // "true"   (a string, not a boolean)
  req.body.name;       // "Ana"
});

How I decide where data belongs: params identify the resource, query filters or modifies how I read it (pagination, sorting, search), and body carries the data I'm creating or changing.

The gotcha is that params and query are always strings, since they come from the URL. "42" === 42 is false, and if (req.query.notify) is truthy even for "false". So I validate and convert them, usually with a Zod schema using z.coerce.number().

Also, if req.body is undefined, the first thing I check is whether express.json() was registered, and whether the client actually sent Content-Type: application/json.

Interviewers look for: "URL values are always strings", and knowing req.body needs a parser.

BasicWhy does GET /users/new return a user lookup for "new" instead of the form?

So it's a route-order problem. Express checks routes top to bottom and uses the first one that matches. :id matches any segment, including the literal word new, so if the dynamic route is registered first, the specific one never runs.

// Broken
app.get("/users/:id", getUser);        // matches /users/new, id = "new"
app.get("/users/new", newUserForm);    // never reached

// Fixed: specific before dynamic
app.get("/users/new", newUserForm);
app.get("/users/:id", getUser);

What usually happens in practice is that getUser tries to look up an ID of "new". With Postgres and a UUID column, that throws a cast error and returns a 500, which is a confusing symptom if you don't know the cause.

There are two things I do to avoid it. First, I order routes from most specific to least specific. Second, I validate params, so if IDs are numeric or UUIDs, an invalid one gets a clean 400 or falls through, instead of hitting the database. Some teams also use different paths entirely, like /users/new living on the frontend only, so it doesn't clash with the API.

The same ordering rule applies across routers. If app.use("/users", userRouter) is mounted after a catch-all, those routes never get a chance.

IntermediateHow would you design the routes for a REST API for orders, including nested resources?

So I model resources as nouns and let the HTTP method be the verb. Then I nest one level only where the child can't exist without the parent.

GET    /orders                  list (with ?status=paid&page=2&limit=20)
POST   /orders                  create
GET    /orders/:orderId         read one
PATCH  /orders/:orderId         partial update
DELETE /orders/:orderId         delete / cancel

GET    /orders/:orderId/items   items belong to an order
POST   /orders/:orderId/items

POST   /orders/:orderId/refund  an action that isn't plain CRUD

A few choices here I'd explain. PATCH for partial updates, PUT only if the client sends the full replacement. Filtering, sorting and pagination go in the query string, not the path. And I keep nesting shallow. If an item has its own ID, GET /items/:itemId is often cleaner than three levels deep.

For things that really are actions, like refunding, I'm fine with a verb sub-resource instead of forcing it into a PATCH on some status field. It's clearer, and it maps nicely to its own permission check.

Status codes matter as much as the paths: 201 with the new resource on create, 204 on delete, 404 when the order isn't found, and 403 when it exists but belongs to someone else. I'd also version from day one, like /api/v1, since changing that later is painful.

Interviewers look for: nouns plus methods, shallow nesting, pagination in the query, and correct status codes.

IntermediateWhat's the difference between app.use() and app.get() when it comes to path matching?

So app.use matches by prefix and any HTTP method. app.get (and post, put and so on) matches the whole path and only that method.

app.use("/api", logger);      // /api, /api/users, /api/users/42, any method
app.get("/api", handler);     // only GET /api exactly

app.use("/admin", adminRouter);
// inside adminRouter, req.url has "/admin" stripped:
// a request to /admin/stats shows req.url === "/stats"
// req.originalUrl is still "/admin/stats"
// req.baseUrl is "/admin"

The prefix stripping is what makes routers composable. The admin router defines router.get("/stats") and doesn't need to know where it's mounted.

Two gotchas I've seen. First, the prefix matches on whole segments, so app.use("/api") does not match /apiary. Good, but people sometimes assume it's a plain startsWith. Second, if I'm logging inside a mounted router and use req.url, I get the stripped path, so my logs show /stats instead of the real URL. For logging I always use req.originalUrl.

So as a rule: use is for middleware and mounting routers, the method functions are for endpoints.

IntermediateHow do you handle wildcard and optional route parameters in Express 5?

So Express 5 uses a newer version of path-to-regexp with a stricter syntax. Wildcards must have a name, and optional parts are written with braces. Regular expression characters inside path strings are no longer supported.

// Wildcard: must be named, captures segments as an array
app.get("/files/*filepath", (req, res) => {
  req.params.filepath;   // GET /files/a/b/c.txt  ->  ["a", "b", "c.txt"]
});

// Optional segment
app.get("/posts{/:year}", (req, res) => {
  req.params.year;       // "2025" for /posts/2025, undefined for /posts
});

// Express 4 style, throws at startup in Express 5
app.get("/files/*", handler);   // Missing parameter name

One thing to notice is that the named wildcard gives me an array of segments, not a string, so I'd join("/") it if I need the full path. And I'd still guard it against .. traversal before touching the file system.

For a catch-all 404 I don't use a wildcard route at all. A plain app.use((req, res) => res.status(404)...) at the end is cleaner and matches every method.

If I really need a pattern, like numeric IDs only, I can still pass a real RegExp object as the path. But usually I'd rather accept any string and validate the param, because I get to return a clear 400 instead of a confusing 404.

AdvancedHow would you version an Express API and roll out a breaking change?

So I usually go with URL versioning, because it's the most visible and the easiest to route, cache and debug. Each version is its own router, and they can share services underneath.

import v1 from "./routes/v1/index.js";
import v2 from "./routes/v2/index.js";

app.use("/api/v1", v1);
app.use("/api/v2", v2);

// v1 marks itself as going away
v1.use((req, res, next) => {
  res.set("Deprecation", "true");
  res.set("Sunset", "Wed, 31 Dec 2026 23:59:59 GMT");
  next();
});

The point is that the version lives only in the routing and the request and response shapes. The business logic in the services stays shared, so v1 and v2 controllers are often thin adapters around the same service.

For the rollout itself, let me give you a real example. We had to change price from a float in dollars to an integer in cents. We shipped v2 with the new shape, and kept v1 working by converting in its controller. We logged v1 usage per API client, used the Deprecation and Sunset headers, and emailed the clients still on v1. Once traffic dropped to zero, we deleted the v1 router.

The other options are a header like Accept: application/vnd.app.v2+json, which keeps URLs clean but is harder to test in a browser and easy for caches to mishandle. For internal APIs with one client, I'd often skip versions entirely and do additive, backwards-compatible changes.

Interviewers look for: shared business logic across versions, and a deprecation plan based on measured usage.

15. Middleware — the heart of Express

BasicWhat is middleware in Express? Write one for me.

So middleware is a function that runs between the request coming in and the final handler. It gets req, res and next, and it does one of three things: calls next() to pass it on, sends a response to end it, or calls next(err) to jump to the error handler.

Let me write a request timer:

function requestTimer(req, res, next) {
  const start = performance.now();

  res.on("finish", () => {
    const ms = (performance.now() - start).toFixed(1);
    console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`);
  });

  next();
}

app.use(requestTimer);

What happens here is that it records the start time, registers a listener for when the response finishes, and immediately calls next(). So it doesn't slow the request down at all. When the handler eventually sends the response, the "finish" event fires and I log the real status and duration.

The same pattern covers most of what an app needs: logging, auth, parsing, CORS, rate limiting, request IDs. They're all just functions in a line.

The classic bug is forgetting all three options. If a middleware neither calls next() nor sends a response, the request just hangs until the client times out.

Interviewers look for: the three outcomes of a middleware, and the hanging-request bug.

BasicWhy does the order of app.use() calls matter? Give an example where getting it wrong causes a bug.

So Express runs middleware and routes in the exact order I register them, and each one only affects what comes after it. So if something is registered too late, it simply never runs for the routes above it.

Let me give you three bugs I've actually seen:

// 1. Auth after the route: the route is public
app.get("/admin/stats", getStats);
app.use(requireAuth);

// 2. Body parser after the router: req.body is undefined
app.use("/api", apiRouter);
app.use(express.json());

// 3. Error handler before the routes: it never catches anything
app.use(errorHandler);
app.use("/api", apiRouter);

The first one is the scary one, because nothing breaks. Tests for the happy path pass and the endpoint is just silently unprotected.

So the order I use is roughly: security headers and CORS first, then body parsing and logging, then rate limiting, then the routes (with auth either global or per-router), then the 404 handler, and the error handler last. I keep all of that in one app.js file so it reads top to bottom like a checklist.

And I'd write a test that hits a protected route with no token and expects 401, so a reordering mistake gets caught automatically.

IntermediateWhat causes "Cannot set headers after they are sent to the client"?

So it means my code tried to send a second response to the same request. Headers go out first, once, so after the first res.json or res.send, any attempt to set a status or header throws.

The most common cause is a missing return:

function requireAuth(req, res, next) {
  if (!req.headers.authorization) {
    res.status(401).json({ error: "Not logged in" });
    // no return, so execution continues...
  }
  next();   // ...and the route handler sends a second response
}

// Fix
if (!req.headers.authorization) {
  return res.status(401).json({ error: "Not logged in" });
}

What makes it confusing is that the client gets the first response, the 401, which looks correct. The error shows up in the server logs from the handler further down.

The other causes I look for are calling next() and also sending a response in the same middleware, sending inside a loop or both branches of a callback, and error handlers that don't check whether headers were already sent. For that last one, Express's own advice is to delegate:

app.use((err, req, res, next) => {
  if (res.headersSent) return next(err);   // let Express close the connection
  res.status(err.status ?? 500).json({ error: err.message });
});

Interviewers look for: the missing return as the top cause, and res.headersSent in the error handler.

IntermediateHow do you pass data from one middleware to the next, like the logged-in user?

So the standard way is to attach it to req. The same req object travels through the whole chain, so anything I set on it in one middleware is visible to everything after it.

async function requireAuth(req, res, next) {
  const token = req.headers.authorization?.replace("Bearer ", "");
  const payload = token && verifyJwt(token);
  if (!payload) return res.status(401).json({ error: "Not logged in" });

  req.user = { id: payload.sub, role: payload.role };
  next();
}

function requireRole(role) {
  return (req, res, next) =>
    req.user.role === role ? next() : res.status(403).json({ error: "Forbidden" });
}

app.delete("/users/:id", requireAuth, requireRole("admin"), deleteUser);

Notice requireRole is a factory: it returns a middleware configured with an argument. That's the pattern for any configurable middleware.

There's also res.locals, which is meant for data used when rendering a view. For an API, req.user is the convention, and in TypeScript I extend the Request type so it's typed.

What I avoid is a module-level variable like let currentUser. Node handles many requests concurrently in one process, so two users' requests would overwrite each other's value. For things like a request ID that deep code needs without passing req around, I use AsyncLocalStorage, which is scoped per request.

IntermediateWhat's the difference between application-level, router-level, route-level and error-handling middleware?

So they're the same kind of function, just attached at different scopes. The scope decides which requests they run for. Error-handling middleware is the one that's actually different, because it takes four arguments.

// Application-level: every request
app.use(helmet());

// Path-scoped: everything under /admin
app.use("/admin", requireAuth, requireRole("admin"));

// Router-level: only requests reaching this router
const router = express.Router();
router.use(auditLog);

// Route-level: just this endpoint
router.post("/users", validate(createUserSchema), createUser);

// Error-handling: four args, runs when next(err) is called or a handler throws
app.use((err, req, res, next) => {
  res.status(err.status ?? 500).json({ error: "Something went wrong" });
});

The four-argument signature is how Express recognises an error handler. It checks the function's length. So if I leave out next because I don't use it, Express treats it as normal middleware and it never receives errors. My linter complains about the unused argument, and I leave it anyway.

In practice, I put cross-cutting things like security and logging at the app level, auth at the router level for whole groups of routes, and validation at the route level, because each endpoint has its own schema.

Interviewers look for: the four-argument signature and why it must stay four.

AdvancedSome requests to your API hang forever with no error in the logs. How do you debug that?

So a hanging request almost always means some function in the chain neither called next() nor sent a response. Or it's waiting on something that never resolves. My approach is to find exactly where in the chain it stops.

First, I narrow it down: which routes, which methods, and is it all requests or some. Then I check the usual suspects:

  • a middleware with a branch that returns without calling next() or sending anything,
  • an async error in Express 4 that became an unhandled rejection, so nobody responded,
  • a callback-style API where the error path doesn't call next(err),
  • an await on something with no timeout: a database pool that's exhausted, or an HTTP call to a service that never responds.

Let me give you a real one. We had random hangs under load only. It turned out to be connection pool exhaustion. One code path checked out a client for a transaction and didn't release it on an early return. After ten of those, every new query waited forever for a free connection.

const client = await pool.connect();
try {
  await client.query("BEGIN");
  // ...
  if (!order) return res.status(404).end();   // used to leak here
  await client.query("COMMIT");
} finally {
  client.release();                           // fix: always release
}

To find it faster next time, I'd add a middleware that logs any request still unfinished after, say, 10 seconds, with its request ID and route. I'd also put timeouts on everything outbound: pool acquire timeouts, AbortSignal.timeout() on fetch, and a server requestTimeout. Then a hang turns into an error I can see.

Interviewers look for: a systematic approach, pool exhaustion as a cause, and adding timeouts so hangs become visible errors.

16. Request & Response

BasicWhat's the difference between req.params, req.query and req.body?

So they're three different places the client can put data. req.params comes from named segments in the path, req.query comes from the query string after the ?, and req.body is the parsed request body. Let me give you an example.

// PATCH /users/42?notify=true
// body: { "name": "Ada" }

app.patch("/users/:id", express.json(), (req, res) => {
  req.params.id;      // "42"      (always a string)
  req.query.notify;   // "true"    (a string, not a boolean)
  req.body.name;      // "Ada"     (only because express.json() ran)
});

The way I decide where data goes: params identify which resource, query modifies how I read it (filters, sorting, pagination), and the body carries the payload I'm creating or changing.

Two gotchas I watch for. First, params and query are always strings, so req.params.id === 42 is false. I coerce and validate them. Second, req.body is only filled if a body parser ran. In Express 5 it's undefined without one, where in Express 4 it was an empty object, so code that did req.body.name now throws instead of quietly returning undefined.

Interviewers look for: "everything from the URL is a string" and knowing req.body needs express.json().

BasicWhat's the difference between 401 and 403? And 400 versus 422?

So 401 means "I don't know who you are": the request isn't authenticated, the token is missing or expired, and the client should log in. 403 means "I know exactly who you are, and you still can't do this".

Let me give you an example from a typical admin endpoint.

function requireAdmin(req, res, next) {
  if (!req.user) {
    return res.status(401).json({ error: "Login required" });
  }
  if (req.user.role !== "admin") {
    return res.status(403).json({ error: "Admins only" });
  }
  next();
}

The distinction matters to the client: on a 401 my frontend tries a token refresh or redirects to login. On a 403, refreshing won't help, so it shows a "no access" message.

For 400 versus 422, I use 400 when the request is malformed or fails schema validation, like a missing field or a string where a number should be. 422 is for input that's well-formed but breaks a business rule, like a booking end date before the start date. Plenty of teams just use 400 for both, and that's fine as long as it's consistent. And for "that email is already registered" I'd reach for 409 Conflict, because it clashes with existing state.

Interviewers look for: authentication vs authorization, and a sensible reason for how the client reacts differently.

BasicWhat's the difference between res.send(), res.json() and res.end()?

So all three finish the response, they just differ in how much they do for me. res.end() comes from Node's raw http module and writes whatever I give it with no headers added. res.send() is Express's smart version, and res.json() is the explicit JSON one.

MethodWhat it does
res.end()Ends the stream. No Content-Type, no serialization. Good for 204.
res.send(x)Looks at the type: string sets text/html, Buffer sets application/octet-stream, object goes to res.json. Adds ETag and Content-Length.
res.json(x)Always JSON.stringify, always application/json, even for a string or null.
res.status(204).end();            // DELETE succeeded, nothing to send
res.send("<h1>Hi</h1>");          // text/html
res.json({ id: 1 });              // application/json
res.json("ok");                   // body is "ok" with quotes, valid JSON

In an API I always use res.json() for bodies, because res.send("ok") would come back as HTML and a client calling response.json() on it blows up. And res.status() alone sends nothing: it only sets the code, so it has to be chained.

IntermediateYou see "Cannot set headers after they are sent to the client" in the logs. What's going on and how do you find it?

So that error means some code path tried to send a second response for one request. HTTP only allows one, and once the headers have gone out Express can't change them. Nine times out of ten it's a missing return.

app.get("/users/:id", async (req, res) => {
  const user = await repo.find(req.params.id);
  if (!user) res.status(404).json({ error: "Not found" });   // no return
  res.json(user);   // runs anyway, throws ERR_HTTP_HEADERS_SENT
});

What happens here is that res.json() sends the reply but doesn't stop the function, so execution falls through to the second send. The fix is return res.status(404).json(...).

The other patterns I look for when it's not that obvious:

  • Calling next() after sending, so a later handler also responds.
  • Sending in a try and then again in the catch, when the error was thrown after the first send.
  • A callback or event (like a stream's error) firing after a timeout middleware already replied.

To find it, the stack trace points at the second send, and I work backwards from there. In an error handler I also check res.headersSent and delegate to next(err) if a response is already partly out, so Express can close the connection properly.

Interviewers look for: knowing res.json() doesn't return from the function, plus the headersSent guard.

IntermediateHow would you set a session cookie securely from Express?

So I set it with res.cookie() and lock it down with three flags: httpOnly so JavaScript on the page can't read it, secure so it only travels over HTTPS, and sameSite to control cross-site sending, which is my main CSRF defence.

res.cookie("sid", sessionId, {
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "lax",
  maxAge: 7 * 24 * 60 * 60 * 1000,   // milliseconds in Express
  path: "/",
});

// logout: options must match or the browser keeps it
res.clearCookie("sid", { path: "/" });

The reason I pick lax by default is that it still sends the cookie on normal top-level navigation, like clicking a link from an email, but not on cross-site POSTs or background fetches. strict is safer but breaks that "click a link and you're logged in" flow. If my frontend and API are on different sites I need sameSite: "none", which requires secure, and then I add a CSRF token.

One gotcha I've hit: behind a load balancer that terminates TLS, Express thinks the request is HTTP. I have to set app.set("trust proxy", 1), otherwise things like req.secure and secure cookies in express-session don't behave.

AdvancedHow do you send a large file or a big export without loading it into memory?

So the short answer is: never build the whole thing as a string or buffer. I stream it into res, because res is a writable stream, and I use pipeline so backpressure and errors are handled for me.

Let me give you an example. In my last project we had a CSV export of a few hundred thousand orders that was OOM-ing the pod because it did rows.map(...).join("\n").

import { pipeline } from "node:stream/promises";
import { Transform } from "node:stream";

app.get("/orders/export.csv", async (req, res, next) => {
  res.set({
    "Content-Type": "text/csv",
    "Content-Disposition": 'attachment; filename="orders.csv"',
  });
  const toCsv = new Transform({
    objectMode: true,
    transform(row, _enc, cb) { cb(null, `${row.id},${row.total}\n`); },
  });
  try {
    await pipeline(db.streamOrders(), toCsv, res);   // DB cursor, row by row
  } catch (err) {
    if (!res.headersSent) return next(err);
    res.destroy(err);    // too late for a JSON error, cut the connection
  }
});

What happens here is that pipeline pauses the database cursor whenever the client is slow to read, so memory stays flat no matter how big the file is. If the client disconnects, it destroys the source too, so I'm not querying for nobody.

The interesting part is error handling. Once bytes are flowing, I can't switch to a 500 JSON response, so I destroy the socket and the client sees a truncated download. For static files, res.sendFile() already streams and handles ranges and ETags, so I'd just use that.

Interviewers look for: res is a stream, backpressure via pipeline, and what to do on an error after headers are sent.

17. Express Request–Response Lifecycle

BasicWalk me through what happens when a request hits an Express app.

So Node's http server accepts the connection, parses the method, URL and headers, and creates req and res. It calls my Express app, which is really just a function (req, res). Express adds its helpers and then walks through my middleware stack in the order I registered it.

Let me give you a concrete request, POST /api/users:

app.use(helmet());           // 1. security headers
app.use(cors());             // 2. CORS headers / preflight
app.use(express.json());     // 3. read body stream -> req.body
app.use(requestLogger);      // 4. log + timer
app.use("/api", authenticate);                      // 5. sets req.user
app.post("/api/users", validate(schema), createUser); // 6. route mw, 7. handler
app.use(notFound);           // only if nothing matched
app.use(errorHandler);       // only on next(err) / throw

Each step has three choices: call next() to pass it on, send a response to finish, or call next(err) to jump to the error handler. Router matching is just part of that walk: Express checks method and path and runs the first matching route. The handler calls the service and the database, and while it's awaiting, the event loop serves other requests. When I call res.json(), the reply goes out and res emits finish, which is where my logger records status and duration.

One detail people miss: the body isn't read when the request arrives. It's still a stream until express.json() consumes it.

Interviewers look for: the three choices at each step, ordering, and where 404 and error handlers sit.

BasicWhy does the order of app.use() and route definitions matter?

So Express keeps one ordered list of middleware and routes, and it walks that list top to bottom for every request. Something registered after a route that sends a response simply never runs for that route.

app.post("/login", (req, res) => {
  res.json({ got: req.body });   // req.body is undefined
});
app.use(express.json());         // too late for /login

app.get("/admin", adminPage);    // public by accident
app.use(requireAuth);            // only protects routes below it

Both of those are bugs I've actually seen. The first gives undefined bodies on one route and it takes a while to spot why. The second is worse, because it's a security hole that no test catches unless someone tests the unauthenticated case.

The same rule applies to routes themselves. If /users/:id is defined before /users/me, then /users/me matches :id with id = "me". So I put specific paths before parameterised ones.

So in practice my order is: security headers and CORS, body parsing, logging, auth, routes, then the 404 handler, then the error handler at the very bottom.

IntermediateA request just hangs until the client times out. No error in the logs. How do you debug it?

So a hang with no error almost always means some middleware neither called next() nor sent a response. Express has no built-in request timeout, so the request just sits there until the load balancer or client gives up, often after 30 to 120 seconds.

Let me give you the kind of bug I mean.

function requireRole(role) {
  return (req, res, next) => {
    if (req.user.role === role) return next();
    if (req.user.role === "banned") {
      return res.status(403).json({ error: "Banned" });
    }
    // any other role: nothing happens, request hangs
  };
}

How I track it down:

  • Log on entry and on res.on("finish") and res.on("close"). A request with "arrived" but no "finish" is the hung one, and close without finish tells me the client gave up.
  • Bisect with a log line per middleware to see which one it enters but never leaves.
  • Check for awaited promises that never settle, like a query waiting on an exhausted connection pool. That looks exactly the same from outside.
  • In Express 4, check for async handlers that throw without a wrapper. The rejection is unhandled and the request hangs.

Once fixed, I also add a safety net: a timeout middleware or server.requestTimeout, plus a pool acquire timeout, so a stuck request becomes a 503 or 504 instead of an invisible hang.

Interviewers look for: "no next and no response" as the first hypothesis, and the pool exhaustion case as the second.

IntermediateHow does Express know a middleware is an error handler? What happens when you call next(err)?

So Express checks the function's arity, meaning fn.length. If it declares exactly four parameters, (err, req, res, next), it's treated as error-handling middleware. That's it, there's no other flag.

// This is an error handler
app.use((err, req, res, next) => {
  res.status(err.statusCode ?? 500).json({ error: err.message });
});

// This is NOT: only 3 params, so Express treats it as normal middleware
app.use((err, req, res) => { /* never receives errors */ });

When I call next(err) with anything other than "route" or "router", Express switches into error mode. It skips every normal middleware and route after that point and runs the next four-argument function. If that handler calls next(err) again, it goes to the next error handler, and if there's none, Express's default handler sends a 500 with the stack in development.

One gotcha I've hit: a linter or a refactor removes the "unused" next parameter from the error handler, the arity drops to three, and suddenly all errors fall through to the default HTML error page. So I keep next even when I don't call it, and disable the unused-arg rule on that line.

IntermediateWhat's the difference between the finish and close events on res?

So finish fires when the whole response has been handed off to the operating system, meaning my app finished its side successfully. close fires when the underlying connection or response is closed, which happens after finish normally, but also on its own if the client disconnects early.

app.use((req, res, next) => {
  const start = performance.now();
  res.on("finish", () => {
    log.info({ status: res.statusCode, ms: performance.now() - start });
  });
  res.on("close", () => {
    if (!res.writableFinished) {
      log.warn({ url: req.originalUrl }, "client aborted");
    }
  });
  next();
});

What happens here is that a normal request logs its status and latency on finish. If the user closes the tab mid-request, finish never fires, but close does with writableFinished false, so I can count aborts.

I actually use that abort signal for real work too. For an expensive search endpoint I create an AbortController, abort it on close, and pass the signal to the database query or fetch, so I stop burning resources on a response nobody will read.

Interviewers look for: using finish for access logs and close for cancellation.

AdvancedWhat changed in the lifecycle between Express 4 and Express 5 that affects how you write handlers?

So the big one is async errors. In Express 5, if a handler or middleware returns a promise that rejects, Express catches it and calls next(err) for me. In Express 4 that rejection was unhandled, the request hung, and depending on Node's settings the process could crash.

// Express 5: this reaches the error handler, no try/catch needed
app.get("/users/:id", async (req, res) => {
  const user = await userService.get(req.params.id);  // throws AppError(404)
  res.json(user);
});

I verified this on Express 5.2: a handler that just does throw new Error("boom") in an async function lands in my four-arg handler.

The other changes I've had to handle when migrating:

  • Path syntax moved to path-to-regexp v8. A bare * now throws at startup, so it becomes a named wildcard like /files/*splat, and optional params use braces: /opt{/:id}. The ? modifier is gone.
  • req.body is undefined without a parser, not {}.
  • req.query is a getter now, so validation middleware that did req.query = parsed throws. I store parsed values on res.locals or a custom property.
  • Removed methods like res.send(status), req.param() and res.redirect("back").

So in practice, on Express 5 I drop the asyncHandler wrappers, but I still write the error handler, because it's the single place that decides what the client sees.

Interviewers look for: async rejection forwarding, and at least one migration gotcha from real experience.

18. Routers & Structure

BasicWhat is express.Router() and why would you use it?

So a Router is a mini-app: it has its own middleware and routes, but it can't listen on a port. I build one per resource in its own file and mount it at a prefix, so the URL prefix lives in one place and the file only knows relative paths.

// routes/orders.routes.js
import { Router } from "express";
const router = Router();
router.get("/", listOrders);          // GET  /api/v1/orders
router.get("/:id", getOrder);         // GET  /api/v1/orders/:id
router.post("/", createOrder);        // POST /api/v1/orders
export default router;

// app.js
app.use("/api/v1/orders", ordersRouter);

The reason I like it is mostly organisation and ownership. When the app grew past a few dozen endpoints in my last project, each team owned a router file, merge conflicts in app.js went away, and versioning was just mounting the same router at a second prefix.

It also lets me scope middleware. A router.use(requireAuth) only runs for requests that entered that router, instead of for the whole app.

IntermediateIn a nested router, req.params from the parent path is empty. Why, and how do you fix it?

So by default each router only sees its own params. The parent's :userId is matched by the parent, not by the child router, so inside the child req.params.userId is undefined. The fix is Router({ mergeParams: true }).

// routes/posts.routes.js
const router = Router({ mergeParams: true });
router.get("/", (req, res) => {
  res.json({ userId: req.params.userId });   // "42" only with mergeParams
});

// app.js
app.use("/users/:userId/posts", postsRouter);
// GET /users/42/posts

It's a really common "why is this undefined" moment the first time someone nests resources.

A related thing I mention: inside a mounted router, req.url and req.path are relative to the mount point, so they'd be / here, while req.originalUrl keeps the full /users/42/posts. That's why my logger and error handler always use originalUrl.

Interviewers look for: mergeParams by name, and bonus points for originalUrl vs url.

IntermediateHow would you apply authentication to most routes but leave a few public?

So I'd rather make the protection structural than remember to add it per route. I split public and private routes into separate routers, and put auth at the mount point of the private one. Then a new private route is protected by default just by living in that file.

// app.js
app.use("/api/auth", authRouter);                 // login, signup, refresh
app.use("/api/health", healthRouter);             // public
app.use("/api", requireAuth, privateRouter);      // everything else

// or inside a single router
router.get("/", listProducts);          // public, above the line
router.use(requireAuth);                // everything below needs a user
router.post("/", createProduct);
router.delete("/:id", requireRole("admin"), deleteProduct);

What I avoid is app.use(requireAuth) with an allow-list of skipped paths inside it, like if (req.path === "/login") return next(). Those lists drift, and it's easy to get path matching subtly wrong with trailing slashes or case.

One gotcha with app.use("/api", requireAuth, privateRouter): it runs for every request under /api that reaches it, including typos that would otherwise be 404s. So unauthenticated users get 401 instead of 404 for unknown paths, which I actually think is fine, since it doesn't leak which routes exist.

IntermediateWhat does next("route") do, and when would you use router.route() or router.param()?

So next("route") skips the remaining handlers on the current route and moves on to the next matching route. It only works in handlers added with app.get, router.post and so on, not in app.use. router.route() groups several methods on one path, and router.param() runs a function whenever a named param appears.

router.param("orderId", async (req, res, next, id) => {
  const order = await repo.findOrder(id);
  if (!order) return res.status(404).json({ error: "Order not found" });
  req.order = order;
  next();
});

router.route("/:orderId")
  .get((req, res) => res.json(req.order))
  .patch(validate(updateSchema), updateOrder)
  .delete(requireRole("admin"), deleteOrder);

What happens here is that any route with :orderId loads the order once, and every handler gets req.order. It removes the same four lines from GET, PATCH and DELETE.

Honestly, I use router.route() a lot, router.param() occasionally, and next("route") almost never. I'd rather have an explicit if in one handler than make control flow jump between routes.

AdvancedHow would you structure an API that needs to run v1 and v2 side by side?

So the goal is to change only what actually differs between versions. I mount a router per version, and each version router reuses the same services, with only the controllers or serializers that changed being version-specific.

// routes/v1/index.js
const v1 = Router();
v1.use("/users", usersRouter);       // shared
v1.use("/orders", ordersV1Router);   // old response shape
export default v1;

// routes/v2/index.js
const v2 = Router();
v2.use("/users", usersRouter);       // same router object, reused
v2.use("/orders", ordersV2Router);   // new pagination + shape
export default v2;

app.use("/api/v1", deprecation("2027-01-01"), v1);
app.use("/api/v2", v2);

The reason this works is that my services don't know about versions. v2 of orders changed the response from an array to { data, nextCursor }, which is purely an HTTP-shape concern, so only the controller differs. The deprecation middleware adds Deprecation and Sunset headers so clients know v1 is going away, and I log v1 traffic per client so I know who still needs to migrate.

I'd use URL versioning over header versioning for a public API, because it's visible in logs, cacheable and easy to test with curl. For an internal API with one frontend, I'd often skip versioning and just make additive changes.

Interviewers look for: reuse across versions, a deprecation plan, and judgement about when versioning is not needed.

Part 4 — Building a Real API

19. Project Layout

BasicHow do you usually structure an Express project?

So I use layers, and the point of each layer is what it doesn't know. Routes map URLs to controllers. Controllers know HTTP but not business rules. Services hold the rules and have never heard of req or res. Repositories are the only place that talks to the database.

src/
  app.js            builds the app, no listen()
  server.js         listen() + graceful shutdown
  config/env.js     validate env vars once at startup
  routes/           users.routes.js
  controllers/      users.controller.js
  services/         users.service.js
  repositories/     users.repo.js
  middleware/       auth.js  validate.js  error.js
  utils/            AppError.js

Let me give you an example of the payoff. In my last project we needed a nightly job that created accounts from a partner's CSV. Because userService.create() took a plain object and not req, the job just called it, with the same duplicate-email check and password hashing as the signup endpoint. No fake request objects.

For a bigger codebase I'd switch to feature folders, like modules/users/ containing its route, controller, service and repo, because then everything about one feature sits together. The layering stays the same, only the folder grouping changes.

Interviewers look for: the responsibility of each layer, not just folder names.

BasicWhy split app.js and server.js?

So app.js builds and exports the Express app, and server.js is the only file that calls listen(). The main reason is testing: my tests can import the app without opening a real port.

// app.js
export function createApp() {
  const app = express();
  app.use(express.json());
  app.use("/api/users", usersRouter);
  app.use(errorHandler);
  return app;
}

// server.js
const server = createApp().listen(env.PORT);
process.on("SIGTERM", () => server.close(() => pool.end()));

// users.test.js
import request from "supertest";
const res = await request(createApp()).get("/api/users");

What happens with Supertest is that it binds the app to an ephemeral port per test, so tests can run in parallel without "port 3000 already in use" errors. If app.js called listen() on import, every test file would try to grab the same port and the test process wouldn't exit cleanly.

It also keeps process concerns in one place: signal handling, graceful shutdown and connecting to the database before accepting traffic all live in server.js. Exporting a factory function instead of a singleton lets tests inject fakes, like a stub repository.

IntermediateWhat belongs in a controller versus a service? Show me a controller that's doing too much.

So a controller translates HTTP in and HTTP out: read params, body and user from req, call a service, pick a status code, send the response. A service makes decisions. If there's an if about business rules in the controller, it's in the wrong place.

Let me give you an example of a fat controller.

// before: rules, DB and HTTP all mixed together
export async function cancelOrder(req, res) {
  const order = await db.query("SELECT * FROM orders WHERE id=$1", [req.params.id]);
  if (order.userId !== req.user.id) return res.status(403).json({});
  if (order.status === "shipped") return res.status(409).json({});
  await db.query("UPDATE orders SET status='cancelled' WHERE id=$1", [order.id]);
  await stripe.refunds.create({ payment_intent: order.paymentId });
  res.json({ ok: true });
}

// after
export async function cancelOrder(req, res) {
  const order = await orderService.cancel(req.params.id, req.user.id);
  res.json(order);
}

In the refactor, the service throws AppError("Not your order", 403) or AppError("Already shipped", 409), and the error handler turns those into responses. Now the cancellation rules are unit-testable with a fake repo, and the admin panel and a support chatbot can call orderService.cancel too.

One small gray area: the service throwing errors with HTTP status codes does leak a bit of HTTP. For a bigger app I'd throw domain errors like NotFoundError and map them to codes in the error handler.

Interviewers look for: "thin controller" with a concrete reason, like reuse outside HTTP or testability.

IntermediateHow do you handle configuration and environment variables?

So I read and validate all environment variables once, at startup, in one module, and the rest of the app imports a typed config object. If something's missing or malformed, the app crashes immediately with a clear message instead of failing on the first request at 3am.

// config/env.js
import { z } from "zod";

const schema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  PORT: z.coerce.number().int().default(3000),
  DATABASE_URL: z.url(),
  JWT_SECRET: z.string().min(32),
});

export const env = schema.parse(process.env);   // throws at boot if invalid

The reason I coerce is that everything in process.env is a string, so PORT would otherwise be "3000". And the reason I centralise it is that I've seen process.env.FEATURE_X === "true" scattered through a codebase with half the places checking "1" instead.

For loading, modern Node has node --env-file=.env built in, so I don't need dotenv for local development anymore. In production the variables come from the platform or a secrets manager. The .env file is git-ignored, and I commit a .env.example with every key and no real values.

IntermediateHow would you make the service layer testable without a real database?

So I pass dependencies in instead of importing them directly inside the service. That's dependency injection, but it doesn't need a framework, a factory function that takes the repo as an argument is enough.

// users.service.js
export function makeUserService({ userRepo, hasher }) {
  return {
    async register({ email, password }) {
      if (await userRepo.findByEmail(email)) {
        throw new AppError("Email already registered", 409);
      }
      return userRepo.insert({ email, password: await hasher.hash(password) });
    },
  };
}

// users.service.test.js
const service = makeUserService({
  userRepo: { findByEmail: async () => ({ id: 1 }), insert: async () => {} },
  hasher: { hash: async (p) => `hashed:${p}` },
});
await expect(service.register({ email: "a@b.com", password: "x" }))
  .rejects.toMatchObject({ statusCode: 409 });

What happens here is that the test runs in milliseconds, with no Postgres and no bcrypt cost factor slowing it down, and I can force edge cases like "email exists" trivially.

I'd still have a smaller set of integration tests hitting a real database in a container, because fakes can't catch a wrong SQL query. So the split is: rules tested with fakes, queries tested against the real thing. The composition root, usually app.js, is where the real repo gets wired in.

Interviewers look for: injection over module mocking, and knowing unit tests with fakes don't replace integration tests.

AdvancedWhen is this layered architecture overkill, and how do you decide?

So for a small service, say five endpoints that mostly read and write rows, full layering is more ceremony than it's worth. A controller that calls a repo, or even runs a query directly, is perfectly readable. I add layers when a real pain shows up, not on day one.

The signals I look for:

  • The same rule appears in two handlers. That's when I pull it into a service.
  • Something other than HTTP needs the logic, like a queue worker or a cron job.
  • Controllers are hard to test because they mix req handling with decisions.
  • More than one team or several developers are working in the same files.
// perfectly fine for a tiny internal service
router.get("/flags", async (req, res) => {
  const { rows } = await pool.query("SELECT key, enabled FROM flags");
  res.json(rows);
});

In my last project we started a webhook receiver with this flat style. When it grew to handle retries and deduplication, the rules moved into a service, and it took an afternoon, because the flat version was small enough to refactor safely.

The one thing I'd do from day one even in a tiny app is the central error handler and env validation. Those are cheap and painful to retrofit.

Interviewers look for: judgement. Saying when a pattern isn't worth it reads as more senior than reciting it.

20. Validation

BasicThe frontend already validates the form. Why validate on the server too?

So the browser isn't a security boundary. Frontend validation is a UX feature for honest users. Anyone can skip it and call my API directly with curl, Postman, or a script, and send whatever they want.

Let me give you an example of what goes wrong without it.

// no validation
app.post("/transfers", async (req, res) => {
  await transferService.send(req.user.id, req.body.to, req.body.amount);
  res.status(201).end();
});

// attacker sends: { "to": 9, "amount": -500 }
// a negative transfer pulls money the other way

The form had min="1" on the input, but that means nothing to someone sending JSON directly. Other things that slip through without server validation are wrong types like "amount": "100" that get string-concatenated, huge strings that bloat the database, and extra fields like "role": "admin" if I spread the body into an insert.

So in practice I validate every input at the edge of the API, and I often share the same zod schema with the frontend in a monorepo, so both sides agree and I'm not writing the rules twice.

Interviewers look for: "the client is untrusted" and a concrete attack, not just "best practice".

BasicHow would you write a reusable validation middleware?

So I write a function that takes a schema and returns middleware. It runs safeParse, returns a 400 with field-level errors if it fails, and otherwise replaces the input with the parsed, cleaned data before calling next().

import { z } from "zod";

const validate = (schema) => (req, res, next) => {
  const result = schema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({
      error: "Validation failed",
      details: result.error.issues.map((i) => ({
        field: i.path.join("."),
        message: i.message,
      })),
    });
  }
  req.body = result.data;    // stripped of unknown keys, correctly typed
  next();
};

const createUser = z.object({
  name: z.string().trim().min(2).max(50),
  email: z.email(),
  password: z.string().min(8).max(128),
});

router.post("/", validate(createUser), usersController.create);

The reason I replace req.body with result.data is that zod strips keys that aren't in the schema by default. So if someone sends isAdmin: true, it's gone before my controller sees it. That's mass-assignment protection for free.

The field-level details array matters for the frontend: it can highlight the exact input. And I always use safeParse instead of parse here so I don't need a try/catch.

IntermediateHow do you validate query strings and route params, given they're always strings?

So I use coercion in the schema, so "20" becomes 20 and gets range-checked, and I set defaults and upper bounds. The query string is the one people forget, and ?limit=10000000 is an easy way to take down a database.

const listQuery = z.object({
  limit: z.coerce.number().int().min(1).max(100).default(20),
  page:  z.coerce.number().int().min(1).default(1),
  sort:  z.enum(["createdAt", "name"]).default("createdAt"),
});

const idParam = z.object({ id: z.uuid() });

const validateQuery = (schema) => (req, res, next) => {
  const r = schema.safeParse(req.query);
  if (!r.success) return res.status(400).json({ error: r.error.issues });
  res.locals.query = r.data;   // not req.query: it's read-only in Express 5
  next();
};

I ran this: {} parses to { limit: 20, ... }, "50" becomes 50, and "999" fails the max.

One gotcha I've hit: in Express 5, req.query is a getter, so a middleware that does req.query = r.data throws "Cannot set property query". I put the parsed values on res.locals instead. Another one is z.coerce.boolean(): it uses JavaScript truthiness, so "false" becomes true. For flags I use z.enum(["true", "false"]) and transform it.

Interviewers look for: bounded pagination and awareness that coercion has sharp edges.

IntermediateValidation vs sanitization: what's the difference, and does validating input protect you from XSS and SQL injection?

So validation checks that input has the right shape and rejects it if not. Sanitization changes input to make it safe, like trimming or stripping HTML. And no, validation alone doesn't protect against injection. The real defences happen at the point where data is used.

ThreatActual defence
SQL injectionParameterised queries, always, no matter what validation said
XSSEscape on output: React/templating auto-escapes; sanitize with DOMPurify only if I must render user HTML
NoSQL injectionValidate types, so { "$gt": "" } never reaches a Mongo query as a password

Let me give you an example of why. A comment field like "Tom's <b>great</b> post" is perfectly valid input. If I tried to validate it into safety, I'd reject real apostrophes and annoy users. Instead I store it as-is, pass it as a query parameter, and let the frontend escape it on render.

Where validation does help is the NoSQL case, because it enforces "this is a string", which blocks operator injection. So in practice: validate shape at the edge, parameterise at the database, escape at the output. Each layer does its own job.

AdvancedHow do you keep validation, TypeScript types and API docs from drifting apart?

So I make the schema the single source of truth and derive everything else from it. With zod, the TypeScript type comes from z.infer, and the OpenAPI spec can be generated from the same schemas, so there's nothing to keep in sync by hand.

// shared/schemas/user.ts
export const CreateUser = z.object({
  name: z.string().min(2),
  email: z.email(),
});
export type CreateUser = z.infer<typeof CreateUser>;

// backend
router.post("/", validate(CreateUser), (req, res) => {
  const input: CreateUser = req.body;   // matches what was validated
});

// frontend form uses the same schema with react-hook-form's zodResolver

In my last project we had a hand-written TypeScript interface and a separate Joi schema, and they drifted. The interface said phone was optional, Joi required it, and the frontend team only found out from 400s in production. Moving to one shared schema in a monorepo package ended that category of bug.

For docs, zod 4 can emit JSON Schema directly with z.toJSONSchema(), and libraries build OpenAPI documents on top of that. I'd also validate responses in tests against the same schemas, which catches the backend returning a field the docs don't mention.

Interviewers look for: one source of truth, and a real drift story.

AdvancedWhere do you put a rule like "the email must not already exist"? In the validation schema?

So no, I keep the schema for shape checks that need nothing but the input. Anything that needs the database or current state is a business rule, and it goes in the service. There are two reasons: separation of concerns, and correctness under concurrency.

// service
async function register(data) {
  try {
    return await userRepo.insert(data);
  } catch (err) {
    if (err.code === "23505") {            // Postgres unique_violation
      throw new AppError("Email already registered", 409);
    }
    throw err;
  }
}

The concurrency part is the interesting one. A "check then insert" with findByEmail first has a race: two signups with the same email arrive at the same moment, both checks see no user, and both inserts go through. The only reliable guard is a unique index in the database. So I let the database enforce it and translate the constraint violation into a 409.

I sometimes keep the pre-check anyway, because it gives a nicer early error and avoids burning a bcrypt hash. But I treat it as UX, and the unique constraint as the actual guarantee.

Interviewers look for: spotting the check-then-act race and relying on a DB constraint.

21. Error Handling

BasicHow do you handle errors centrally in Express?

So I have one error-handling middleware with four arguments, registered last. Every route and service either throws or calls next(err), and that one handler decides the status code, logs the details, and sends a consistent JSON shape.

// utils/AppError.js
export class AppError extends Error {
  constructor(message, statusCode = 400, code) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.isOperational = true;
  }
}

// middleware/error.js
export function errorHandler(err, req, res, next) {
  if (res.headersSent) return next(err);
  const status = err.statusCode ?? 500;
  req.log.error({ err, status }, "request failed");
  res.status(status).json({
    error: err.isOperational ? err.message : "Something went wrong",
    code: err.code,
    requestId: req.id,
  });
}

// app.js: after all routes
app.use(notFound);
app.use(errorHandler);

The payoff is that my handlers are clean, they just throw new AppError("Order not found", 404), and every error response in the API has the same shape, so the frontend handles them in one place. The requestId in the response means a user can quote it to support, and I can find the exact log line.

Interviewers look for: four-arg signature, registered last, and not leaking internals on a 500.

BasicWhat's the difference between operational errors and programmer errors?

So operational errors are things I expect can happen in a correct program: invalid input, not found, a duplicate email, a third-party API timing out. Programmer errors are bugs: undefined is not a function, reading a property of null, a wrong argument type.

OperationalProgrammer
ExampleAppError("Email taken", 409)TypeError: user.name of undefined
Client seesThe real message"Something went wrong", 500
I doLog at info/warnLog at error, alert, fix the code

The reason the split matters is what I tell the client. An operational message is safe and useful, so I show it. A programmer error's message can contain file paths, SQL, or table names, so I hide it and send a generic message plus a request ID.

It also changes alerting. In my last project we paged on the rate of 5xx responses, not 4xx, because a spike of 400s is usually a client bug or a bot, while a spike of 500s means our code is broken.

The isOperational flag on AppError is how the error handler tells them apart.

IntermediateHow are async errors handled in Express 4 versus Express 5?

So in Express 4, the router only catches synchronous throws. If an async handler rejects, Express never sees it, the request hangs, and you get an unhandled rejection, which on modern Node crashes the process by default. So you had to catch and call next(err) yourself, or use a wrapper.

// Express 4: wrap every async handler
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get("/users", asyncHandler(async (req, res) => {
  res.json(await userService.list());
}));

// Express 5: returned promises are handled for you
app.get("/users", async (req, res) => {
  res.json(await userService.list());   // rejection goes to next(err)
});

What the wrapper does is turn the handler's return value into a promise and attach .catch(next), which is exactly what Express 5 now does internally.

One gotcha that still exists in Express 5: it only catches the promise the handler returns. If I throw inside a callback, like a setTimeout or an old-style fs.readFile callback, nobody catches it. So I stick to promise-based APIs inside handlers, and I don't do fire-and-forget promises without a .catch.

Interviewers look for: why Express 4 misses async errors, and the callback edge case in Express 5.

IntermediateWhat should you do on uncaughtException or unhandledRejection?

So I treat them as a last-resort alarm, not an error handler. I log the error with as much context as I can, try to shut down gracefully, and exit with a non-zero code so the process manager starts a fresh instance. I never just log and keep serving.

function fatal(err, origin) {
  logger.fatal({ err, origin }, "fatal error, shutting down");
  server.close(() => process.exit(1));          // stop accepting, finish in-flight
  setTimeout(() => process.exit(1), 10_000).unref();  // don't wait forever
}

process.on("uncaughtException", (err) => fatal(err, "uncaughtException"));
process.on("unhandledRejection", (err) => fatal(err, "unhandledRejection"));

The reason I restart is that after an uncaught exception the process is in an unknown state. Maybe a transaction was half-done, a lock is still held, or some module's internal state is corrupted. Continuing risks serving wrong data, which is worse than a brief restart.

Since Node 15, an unhandled rejection crashes the process by default anyway, so the handler is mostly about logging it properly first. The unref() on the timer means it won't keep the process alive on its own, but it guarantees an exit if server.close hangs on keep-alive connections. And this only works because something like Kubernetes, Docker or PM2 is there to restart it.

IntermediateWhat does a good API error response look like?

So it has a correct status code, a consistent shape across every endpoint, a stable machine-readable code the client can switch on, a human message, and a request ID for correlation. Many teams now use RFC 9457 "problem details" for the shape.

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/email-taken",
  "title": "Email already registered",
  "status": 409,
  "code": "EMAIL_TAKEN",
  "detail": "An account with this email already exists.",
  "requestId": "01J9Z6K3..."
}

The reason I include code is that clients shouldn't parse English messages. If I reword "Email already registered", the frontend's if (message === ...) breaks. With EMAIL_TAKEN it switches on something stable, and the message can even be localised.

For validation errors I add an array of field errors so the form can highlight each input. What I leave out: stack traces, SQL, internal service names, and anything that tells an attacker whether an account exists when that matters, like on login or password reset, where I return the same response either way.

Interviewers look for: consistency and a stable error code, not just "return 500".

AdvancedA downstream payment API is flaky. How do you handle its errors in your Express service?

So I separate three things: a timeout so I never wait forever, retries only for errors that are safe to retry, and a clear mapping of their failure to my response. And the retry question depends on whether the call is idempotent.

async function charge(order) {
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      return await payments.charge(
        { amount: order.total, orderId: order.id },
        {
          signal: AbortSignal.timeout(5000),
          headers: { "Idempotency-Key": `order-${order.id}` },
        }
      );
    } catch (err) {
      const retryable = err.name === "TimeoutError" || err.status >= 500 || err.status === 429;
      if (!retryable || attempt === 3) {
        throw new AppError("Payment provider unavailable", 502, "PAYMENT_UPSTREAM");
      }
      await sleep(2 ** attempt * 200 + Math.random() * 100);   // backoff + jitter
    }
  }
}

What happens here is: timeouts, 5xx and 429 get retried with exponential backoff and jitter so I don't hammer them in sync with every other instance. A 4xx like "card declined" is not retried, it's a real answer and I pass it to the user.

The idempotency key is the critical part. If my request timed out, I don't know if the charge happened. Without the key, a retry could charge twice. With it, the provider returns the original result.

If the provider is down for a while, I'd add a circuit breaker so requests fail fast with a 503 instead of every user waiting 15 seconds. And I'd map their failure to a 502 or 503, never pass their raw error to my client.

Interviewers look for: idempotency before retries, timeouts, and not retrying 4xx.

22. Databases

BasicWhat is connection pooling and why do you need it?

So opening a database connection is expensive: a TCP handshake, often TLS, authentication, and memory on the database server, easily 20 to 100 milliseconds before any query runs. A pool opens a set of connections once and lends them out. A request borrows one, runs its query, and gives it back.

import pg from "pg";

export const pool = new pg.Pool({
  connectionString: env.DATABASE_URL,
  max: 20,                        // per process
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 2_000, // fail fast if the pool is exhausted
});

// borrow, query, return: all in one call
const { rows } = await pool.query("SELECT * FROM users WHERE id = $1", [id]);

The reason I create the pool once at module level is that I've seen code that created a new Client per request. Under load it hit Postgres's connection limit, which is 100 by default, and new requests started failing with "too many clients".

The sizing trap is multiplication: 10 instances with max: 50 each is 500 connections. The total across all instances has to stay under the database's limit, so I start around 10 to 20 per instance. On serverless, where instances scale out fast, I put PgBouncer or the provider's pooler in front.

Interviewers look for: the "instances times pool size" calculation.

BasicWhat is SQL injection and how do you prevent it in Node?

So SQL injection is when user input gets concatenated into a query and changes what the query does. The fix is parameterised queries: the SQL and the values are sent separately, so the database parses the query with an empty slot first, and the value can never be read as SQL.

// vulnerable
const q = `SELECT * FROM users WHERE email = '${email}'`;
// email = "' OR '1'='1"  ->  ... WHERE email = '' OR '1'='1'
// returns every user

// safe
await pool.query("SELECT * FROM users WHERE email = $1", [email]);

What happens in the vulnerable version is that the attacker's quote closes my string, and OR '1'='1' becomes part of the logic. In the safe version, ' OR '1'='1 is just a weird email that matches nothing.

A few places people still get caught even with an ORM:

  • Raw query escape hatches, like Prisma's $queryRawUnsafe with a template string.
  • Dynamic column names or sort directions, which can't be parameterised. For those I use an allow-list: const col = { name: "name", date: "created_at" }[req.query.sort] ?? "created_at".
  • MongoDB operator injection, where { "$ne": null } as a password matches anything. Validating it's a string fixes that.
IntermediateWhat is the N+1 query problem? How do you spot it and fix it?

So N+1 is when I run one query to get a list, then one more query per item to load related data. So 100 posts become 101 round trips. It's invisible with ten test rows and falls over with real data.

// N+1: 1 + posts.length queries
const posts = await prisma.post.findMany({ take: 100 });
for (const p of posts) {
  p.author = await prisma.user.findUnique({ where: { id: p.authorId } });
}

// fix 1: let the ORM batch it
const posts = await prisma.post.findMany({
  take: 100,
  include: { author: true },
});

// fix 2: by hand, two queries total
const ids = [...new Set(posts.map((p) => p.authorId))];
const { rows } = await pool.query("SELECT * FROM users WHERE id = ANY($1)", [ids]);
const byId = new Map(rows.map((u) => [u.id, u]));
posts.forEach((p) => (p.author = byId.get(p.authorId)));

How I spot it: I turn on query logging in development, and if one page triggers 200 queries, that's it. In production, APM traces show a waterfall of identical tiny queries under one request.

In a GraphQL API it's even easier to hit, because each resolver fetches independently. There I use DataLoader, which collects all the IDs requested in one tick and runs a single batched query.

Interviewers look for: how you'd detect it, not only how to fix it.

IntermediateHow do you run a transaction with pg, and what's the classic mistake?

So a transaction has to run on one connection, so I check out a client from the pool, run BEGIN, my queries and COMMIT on that same client, ROLLBACK on any error, and release the client in a finally.

async function transfer(from, to, amount) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    await client.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [amount, from]);
    await client.query("UPDATE accounts SET balance = balance + $1 WHERE id = $2", [amount, to]);
    await client.query("COMMIT");
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}

The classic mistake is using pool.query() for the steps. Each pool.query can grab a different connection, so BEGIN runs on one, the updates run on others, and there's no transaction at all. It looks right and silently isn't.

The second one is forgetting release(). Each leaked client is gone from the pool, and after 20 errors the pool is empty and every request hangs waiting for a connection. That's why it's in finally.

And I keep transactions short. I never call an external API or send an email inside one, because I'd be holding locks and a connection while waiting on someone else's server. With Prisma I'd use prisma.$transaction(async (tx) => ...), which handles this for me.

IntermediateORM, query builder, or raw SQL: how do you choose?

So it's a trade-off between productivity and control. I'll usually pick based on the team and the query complexity, not on ideology.

OptionExamplesGood forWatch out for
ORMPrisma, TypeORM, MongooseFast CRUD, migrations, typed resultsHidden N+1s, awkward complex queries
Query builderDrizzle, Kysely, KnexTyped SQL-shaped code, dynamic filtersStill need to know SQL well
Raw SQLpg, postgresReports, CTEs, window functions, hot pathsNo types without extra tooling, manual mapping

Let me give you an example. In my last project we used Prisma for about 90% of the app, the usual create-read-update flows, and it was great for velocity and migrations. But an analytics endpoint needed a CTE with window functions, and the Prisma version was slow and unreadable, so we wrote that one in raw SQL through $queryRaw with a tagged template, which is still parameterised.

These days I lean toward Drizzle or Kysely for new TypeScript projects, because they give me type safety while the code still reads like SQL, so there's less magic to debug.

Whatever I pick, it lives in the repository layer, so switching later only touches those files.

AdvancedTwo users buy the last item in stock at the same time. How do you stop overselling?

So this is a race condition, and the fix is to make the check and the update one atomic operation in the database. Checking stock in JavaScript and then writing is the bug, because both requests read "1 left" before either writes.

// race: read, check in JS, write
const { rows: [p] } = await pool.query("SELECT stock FROM products WHERE id = $1", [id]);
if (p.stock < 1) throw new AppError("Out of stock", 409);
await pool.query("UPDATE products SET stock = stock - 1 WHERE id = $1", [id]);

// fix 1: conditional atomic update
const { rowCount } = await pool.query(
  "UPDATE products SET stock = stock - 1 WHERE id = $1 AND stock > 0",
  [id]
);
if (rowCount === 0) throw new AppError("Out of stock", 409);

// fix 2: lock the row inside a transaction
await client.query("SELECT stock FROM products WHERE id = $1 FOR UPDATE", [id]);

What happens with fix 1 is that Postgres takes a row lock for the update. The second request waits, then re-evaluates stock > 0 against the new value, finds it false, and updates zero rows. rowCount tells me who won. I also add a CHECK (stock >= 0) constraint as a backstop.

I use SELECT ... FOR UPDATE when I need several steps under the lock, like creating an order and a reservation. For low-contention cases, optimistic locking with a version column works well: update WHERE version = $n and retry if zero rows changed.

The thing I'd stress is that because Node is single-threaded, people assume there's no race. But every await is a point where another request can run, and multiple instances run in parallel anyway.

Interviewers look for: atomic conditional update or row locking, and not assuming single-threaded means race-free.

23. Authentication

BasicWhat is the difference between authentication and authorisation? When do you return 401 vs 403?

So authentication is "who are you?", proving identity, usually by logging in and then sending a token or session cookie. Authorisation is "are you allowed to do this specific thing?", and it's a separate check that happens after I know who you are.

The status codes follow that split. 401 means I don't know who you are: no token, an expired token, or a bad signature. 403 means I know exactly who you are, and the answer is still no.

app.delete("/posts/:id", requireAuth, async (req, res) => {
  // requireAuth already sent 401 if there was no valid token

  const post = await Post.findById(req.params.id);
  if (!post) return res.status(404).json({ error: "Not found" });

  if (String(post.userId) !== req.user.userId && req.user.role !== "admin") {
    return res.status(403).json({ error: "Not allowed" });   // known user, wrong owner
  }

  await post.deleteOne();
  res.status(204).end();
});

What happens here is that requireAuth handles the 401 case, and the handler handles the 403 case, because only the handler knows which resource is being touched. The mistake I see most often is stopping after requireAuth: the user is logged in, so the code assumes they can delete anything.

One nuance: for sensitive resources I sometimes return 404 instead of 403, so I don't confirm that record 101 exists to someone who shouldn't know about it.

Interviewers look for: the ownership check inside the handler, not just a login check. Mentioning the 404-instead-of-403 trick shows you think about information leakage.

BasicHow do you store passwords? Why not just SHA-256 them?

So I never store anything I can turn back into the password. I store a slow, salted hash made with a password-hashing algorithm, bcrypt or argon2id, and at login I hash what the user typed and compare.

import bcrypt from "bcrypt";

// signup
const passwordHash = await bcrypt.hash(password, 12);   // salt is generated and embedded
await User.create({ email, passwordHash });

// login
const user = await User.findOne({ email });
const ok = user && await bcrypt.compare(password, user.passwordHash);
if (!ok) return res.status(401).json({ error: "Invalid email or password" });

The reason SHA-256 is wrong here is that it's designed to be fast. A GPU can compute billions of SHA-256 hashes a second, so if the database leaks, an attacker can try every common password against every row very quickly. bcrypt's cost factor makes each hash take around a quarter of a second on purpose. That's nothing for one login, but it's brutal for someone guessing millions.

The salt is a random value mixed into each hash, so two users with the password "hunter2" get different hashes. That kills precomputed lookup tables. With bcrypt I don't manage it myself; it's stored inside the hash string.

A couple of practical things: I always use the async hash/compare, because hashSync blocks the event loop for every login. And the error message is always "invalid email or password", so I don't reveal which emails are registered. For new projects I'd lean towards argon2id, since it's memory-hard and that hurts GPU attacks even more.

IntermediateSessions or JWTs? Which would you pick for a new app, and why?

So the core difference is where the state lives. With sessions, the server stores who's logged in, in Redis or the database, and the browser only holds a random ID in a cookie. With a JWT, the claims travel inside a signed token, and the server just verifies the signature.

Session + storeJWT
Per requestOne Redis lookupVerify a signature
Log out / ban nowDelete the sessionHard, valid until expiry
Across servicesAll need the storeAnyone with the key can verify
SizeTiny cookieLarger header on every request

For a typical web app where my own frontend talks to my own backend, I'd honestly pick sessions in an httpOnly cookie. Revocation is trivial, and a Redis lookup is well under a millisecond. The "JWTs scale better" argument rarely matters at the scale most of us work at.

Where JWTs genuinely win is when several services need to verify identity without calling back to an auth server, or with mobile clients and third-party APIs. In my last project we had an API gateway and five services, so we used short-lived JWT access tokens signed with RS256. Each service verified them with the public key, and a refresh token stored server-side handled revocation.

So in practice: sessions for a monolith with a browser client, JWTs when verification has to be distributed. And in either case, a way to revoke access.

Interviewers look for: a reasoned choice rather than "JWT because it's modern". Revocation is the key trade-off to name.

IntermediateWhere should the browser store a token: localStorage or a cookie?

So my default is an httpOnly, Secure, SameSite cookie. localStorage is readable by any JavaScript on the page, so a single XSS bug means the attacker can read the token and use it from their own machine.

res.cookie("access_token", token, {
  httpOnly: true,        // JS can't read it, so XSS can't steal it
  secure: true,          // HTTPS only
  sameSite: "lax",       // not sent on cross-site POSTs
  maxAge: 15 * 60 * 1000,
  path: "/",
});

The trade-off is that cookies are sent automatically, which opens the door to CSRF: another site gets the user's browser to send a request to my API with the cookie attached. SameSite=Lax already blocks cross-site POST, PUT and DELETE in modern browsers, which covers most of it. For anything really sensitive, or if I need SameSite=None for a cross-site setup, I add a CSRF token as well.

One thing I'm careful to say: httpOnly doesn't make XSS harmless. An attacker's script can still make requests from the user's browser while they're on the page. What it prevents is the token being stolen and replayed later from somewhere else. So XSS prevention still matters.

If the client is a mobile app, there's no cookie jar concern, and I'd send a Bearer token from secure device storage instead.

IntermediateHow do access tokens and refresh tokens work together? How do you log someone out?

So the idea is to get the cheap verification of a JWT without being stuck with a token nobody can revoke. The access token is short-lived, around 15 minutes, and sent on every request. The refresh token is long-lived, stored server-side (usually as a hash), and only used to get a new access token.

app.post("/auth/refresh", async (req, res) => {
  const presented = req.cookies.refresh_token;
  const record = await RefreshToken.findOne({ hash: sha256(presented) });

  if (!record || record.revokedAt || record.expiresAt < new Date()) {
    // a revoked token being reused is a sign of theft: kill the whole family
    if (record?.revokedAt) await RefreshToken.revokeFamily(record.familyId);
    return res.status(401).json({ error: "Please log in again" });
  }

  // rotation: the old refresh token is single-use
  await record.updateOne({ revokedAt: new Date() });
  const next = await RefreshToken.issue(record.userId, record.familyId);

  res.cookie("refresh_token", next.raw, { httpOnly: true, secure: true, sameSite: "strict", path: "/auth" });
  res.json({ accessToken: signAccess(record.userId) });
});

What happens here is rotation: every refresh swaps the old refresh token for a new one. If an attacker steals one and uses it, then the legitimate client tries it too, I see a revoked token reused and revoke the whole family. That forces a fresh login.

Logout is then just revoking the refresh token. The access token still works for up to 15 minutes, and I'm upfront about that. If a product genuinely needs instant revocation, for example "ban this user now", I keep a small denylist of token IDs in Redis with a TTL equal to the token's remaining life.

Interviewers look for: rotation and reuse detection, plus honesty that access tokens stay valid until they expire.

AdvancedWhat are the common JWT implementation mistakes you'd look for in a code review?

So JWTs are simple to use and easy to get subtly wrong. There are a handful of things I check every time.

// risky
const payload = jwt.decode(token);                  // decode does NOT verify
jwt.verify(token, secret);                          // accepts whatever alg the lib allows

// what I want to see
const payload = jwt.verify(token, publicKey, {
  algorithms: ["RS256"],          // pin the algorithm
  issuer: "https://auth.myapp.com",
  audience: "orders-api",
  clockTolerance: 5,
});

First, jwt.decode used for auth. It only Base64-decodes, so anyone can forge the payload. I've actually seen this in a middleware because it "worked in testing".

Second, not pinning the algorithm. The classic attacks were alg: none, and algorithm confusion, where a server expecting RS256 is tricked into verifying an HS256 token using the public key as the HMAC secret. Modern libraries defend against both, but passing algorithms explicitly closes the door.

Third, weak HS256 secrets like "secret" or "changeme". Those can be brute-forced offline from a single token. I want at least 32 random bytes, or asymmetric keys.

Fourth, no exp, or a very long one, plus no check of iss and aud, so a token meant for one service is accepted by another.

Fifth, sensitive data in the payload. It's encoded, not encrypted.

Finally, key rotation. With RS256 I put a kid in the header and publish a JWKS, so I can roll keys without logging everyone out.

AdvancedYour API checks login on every route, but a pentest found users could read each other's invoices. How do you fix it properly?

So that's broken object-level authorisation, or IDOR. It's the number one item on the OWASP API Top 10. The route checks that you're logged in, then loads /invoices/:id by ID alone, so changing the number in the URL gets someone else's invoice.

The quick fix is an ownership check in that handler. But the real fix is making it hard to forget, because the next developer will write another route the same way.

// repository layer: every query is scoped to the tenant/user
export const invoiceRepo = {
  findForUser(userId, invoiceId) {
    return db.invoice.findFirst({ where: { id: invoiceId, userId } });
  },
};

app.get("/invoices/:id", requireAuth, async (req, res) => {
  const invoice = await invoiceRepo.findForUser(req.user.id, req.params.id);
  if (!invoice) return res.status(404).json({ error: "Not found" });
  res.json(invoice);
});

What happens here is that the user ID is part of the query itself. So there's no path where I fetch first and forget to check afterwards. A missing or foreign invoice looks identical: 404.

In my last project, a multi-tenant SaaS, we went further. We put tenantId on every table, used Postgres row-level security with the tenant set per request, and made the repository functions require a tenantId argument. We also added integration tests that log in as user A and hit every route with user B's IDs, and those tests run in CI.

So in practice: scope queries by owner, centralise it, and test it explicitly. Random or UUID IDs help a little, but they're not a substitute for the check.

Interviewers look for: a systemic fix (scoped repositories, RLS, cross-user tests), not just an if in one handler.

24. File Uploads

BasicWhy doesn't express.json() handle file uploads? What do you use instead?

So a browser sends files as multipart/form-data: the body is split into parts separated by a boundary string, each with its own headers, and file parts are raw bytes. express.json() only parses bodies with a JSON content type, so it just skips these requests, and req.body stays empty.

I use multer, which is built on busboy and streams through the multipart parts for me.

import multer from "multer";

const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024, files: 1 },
});

app.post("/avatar", requireAuth, upload.single("avatar"), async (req, res) => {
  if (!req.file) return res.status(400).json({ error: "No file" });
  // req.file: { fieldname, originalname, mimetype, size, buffer }
  // req.body: the text fields from the same form
  const url = await storage.put(`avatars/${crypto.randomUUID()}`, req.file.buffer);
  res.status(201).json({ url });
});

What happens here is that upload.single("avatar") reads the multipart body, puts the file on req.file, and puts the other form fields on req.body. The field name has to match what the client uses in FormData.

One gotcha I've hit: when the file is too big, multer calls next with a MulterError whose code is LIMIT_FILE_SIZE. If the error handler doesn't recognise it, the user gets a generic 500 instead of a clear 413. So I map that error explicitly.

IntermediateHow do you validate an uploaded file safely?

So I treat everything about an upload as attacker-controlled: the filename, the size and the MIME type. The mimetype multer gives me comes straight from the client, so it's a hint, not a check.

There are really four things I do.

import { fileTypeFromBuffer } from "file-type";

const ALLOWED = new Set(["image/jpeg", "image/png", "image/webp"]);

async function checkImage(file) {
  // 1. size: enforced by multer limits before we get here
  // 2. real type from the magic bytes, not the header
  const detected = await fileTypeFromBuffer(file.buffer);
  if (!detected || !ALLOWED.has(detected.mime)) {
    throw new AppError(415, "Only JPEG, PNG or WebP images");
  }
  // 3. our own name, never the client's
  return { key: `uploads/${crypto.randomUUID()}.${detected.ext}`, mime: detected.mime };
}

First, a hard size limit so one request can't exhaust memory or disk. Second, I check the file signature, the first bytes of the file. Every PNG starts with the same eight bytes, so an .exe renamed to .png gets caught. Third, I generate my own filename, because ../../etc/passwd or a name with odd Unicode is a real attack. Fourth, I serve user files from a separate domain or bucket with Content-Disposition: attachment where it makes sense, so an uploaded HTML or SVG file can't run scripts on my main origin.

For images specifically, I like re-encoding them with sharp. That strips EXIF metadata, including GPS location, and anything smuggled inside the file. For documents in a high-risk context, I'd add virus scanning as an async step before the file becomes visible.

Interviewers look for: "the MIME type is client-controlled" and magic-byte checking. SVG as an XSS vector is a nice bonus.

IntermediateMemory storage, disk storage or streaming: how do you choose?

So it depends on file size and where the file ends up. I think of it as three options.

ApproachGood forRisk
memoryStorageSmall files (avatars) you process then send onWhole file in RAM; 50 concurrent 20 MB uploads is 1 GB
diskStorageMedium files, processing that needs a pathTemp files to clean up; disk lost on redeploy
Stream straight throughLarge files going to S3More code; errors mid-stream to handle

Let me give you an example. For profile pictures I use memory storage with a 5 MB limit, because I'm resizing with sharp anyway and it takes a buffer. For a 500 MB video, buffering in memory would be a disaster, so I'd stream the incoming part straight to S3:

import busboy from "busboy";
import { Upload } from "@aws-sdk/lib-storage";

app.post("/videos", requireAuth, (req, res, next) => {
  const bb = busboy({ headers: req.headers, limits: { fileSize: 2 * 1024 ** 3, files: 1 } });
  bb.on("file", (name, fileStream, info) => {
    const upload = new Upload({
      client: s3,
      params: { Bucket: "videos", Key: crypto.randomUUID(), Body: fileStream },
    });
    upload.done().then(() => res.status(201).json({ ok: true })).catch(next);
  });
  req.pipe(bb);
});

What happens here is that bytes flow from the socket, through busboy, into a multipart S3 upload, and memory stays roughly constant whatever the file size. That said, for really large files I'd rather not have them touch my server at all, and use presigned URLs.

AdvancedDesign uploads for files up to 5 GB. Walk me through it.

So at that size I wouldn't proxy the bytes through Node at all. The API's job is to authorise the upload and record it. The file itself goes directly from the browser to object storage using presigned URLs.

// 1. client asks permission
app.post("/uploads", requireAuth, async (req, res) => {
  const { filename, size, contentType } = uploadSchema.parse(req.body);
  const key = `raw/${req.user.id}/${crypto.randomUUID()}`;

  const { UploadId } = await s3.send(new CreateMultipartUploadCommand({ Bucket, Key: key, ContentType: contentType }));
  const partCount = Math.ceil(size / PART_SIZE);            // e.g. 64 MB parts
  const urls = await Promise.all(
    Array.from({ length: partCount }, (_, i) =>
      getSignedUrl(s3, new UploadPartCommand({ Bucket, Key: key, UploadId, PartNumber: i + 1 }), { expiresIn: 3600 }))
  );

  await File.create({ key, ownerId: req.user.id, status: "pending", filename });
  res.json({ key, uploadId: UploadId, urls });
});
// 2. browser PUTs each part directly to S3, retrying failed parts
// 3. client calls POST /uploads/:key/complete with the part ETags
// 4. S3 event -> queue -> worker verifies type, scans, sets status "ready"

The reason I like this: my servers never hold the bytes, so no memory spikes, no request timeouts, and no bandwidth bill through the app tier. Multipart upload means a failed part is retried on its own, which gives me resumability almost for free.

The trust boundary moves, though. The client could lie about size or type. So I enforce a content-length range in the presigned policy where possible, and I never mark a file as "ready" until the worker has checked the real object. Anything left in "pending" gets cleaned up, and I set an S3 lifecycle rule to abort incomplete multipart uploads after a day, because otherwise they quietly cost money.

Interviewers look for: keeping large bytes off the app server, the pending-to-ready state machine, and cleanup of abandoned uploads.

AdvancedUsers on slow connections see uploads fail around 30 seconds in. What's going on?

So a clean failure at a fixed time almost always means a timeout somewhere in the chain, not a bug in the upload code. My first step is finding which hop cuts it off.

There are usually several candidates:

// Node's own http server timeouts
server.requestTimeout;   // default 300000 ms (5 min) since Node 18
server.headersTimeout;   // default 60000 ms
server.setTimeout(ms);   // socket inactivity, default 0 (off)

// but in front of Node there's usually:
//   nginx client_body_timeout / proxy_read_timeout
//   load balancer idle timeout (60s on AWS ALB by default)
//   a platform request limit (e.g. 30s on some PaaS routers)

In my experience, a round number like 30 seconds points at the platform or proxy, because Node's defaults are 5 minutes. I'd check the proxy logs for 408 or 504 responses, and reproduce with a throttled upload: curl --limit-rate 50k -F file=@big.bin.

The fix depends on the cause. Raising the limit on one proxy works for moderate files. But if the files are big and users are on mobile, the real fix is to stop sending long uploads through the app at all. I'd switch to presigned direct-to-S3 uploads with multipart and per-part retries, so a flaky connection only redoes a few megabytes.

One more thing to check is memory: if the upload used memoryStorage, a slow client holds a growing buffer for the whole time, so many slow uploads at once can also push the process towards an OOM kill.

Part 5 — Production

25. Security

BasicWhat's your security baseline for a new Express API?

So the mindset is that anything coming from outside is untrusted. Then there's a short baseline I put in on day one, because it's cheap and prevents the boring attacks.

import helmet from "helmet";
import cors from "cors";
import rateLimit from "express-rate-limit";

app.disable("x-powered-by");                              // helmet does this too
app.set("trust proxy", 1);                                // correct req.ip behind one proxy
app.use(helmet());                                        // security headers
app.use(cors({ origin: ["https://app.example.com"], credentials: true }));
app.use(express.json({ limit: "100kb" }));                // cap body size
app.use("/auth", rateLimit({ windowMs: 15 * 60_000, limit: 10 }));

What this gives me: helmet sets headers like Strict-Transport-Security, X-Content-Type-Options: nosniff and a default Content Security Policy. CORS is an explicit allowlist instead of *. The body limit stops someone posting a 500 MB JSON blob. And the login routes get rate limited against brute force.

The trust proxy line is the one people miss. Behind a load balancer, without it every request looks like it comes from the balancer's IP, so the rate limiter either blocks everyone or nobody.

Beyond middleware, the baseline includes validating every input with a schema, parameterised queries, secrets only from environment variables, npm audit in CI, and running the container as a non-root user. None of it is exciting, but most real breaches are one of these being skipped.

Interviewers look for: the reasons behind each line, and "trust proxy" if you mention rate limiting.

BasicHow do you prevent SQL injection in Node?

So the rule is to never build SQL by concatenating user input. I use parameterised queries, where the SQL text and the values travel to the database separately. The database never parses a value as SQL, whatever it contains.

// vulnerable
const email = req.query.email;   // "' OR '1'='1"
await pool.query(`SELECT * FROM users WHERE email = '${email}'`);
// becomes: WHERE email = '' OR '1'='1'  -> every user

// safe (node-postgres)
await pool.query("SELECT * FROM users WHERE email = $1", [email]);

// safe (Prisma): tagged template is parameterised
await prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`;
// NOT safe: $queryRawUnsafe with a string you built yourself

What happens with the vulnerable version is that the quote in the input closes the string early, and the rest becomes part of the query. With $1, the value is sent as data, so the database just looks for a user whose email is literally that weird string.

The gotcha I watch for is the places where parameters can't go: column names in ORDER BY, table names, and sort direction. You can't parameterise an identifier. So for a ?sort= query param, I map it through an allowlist, like { newest: "created_at DESC", name: "name ASC" }, and reject anything else.

ORMs and query builders parameterise by default, which is a big part of why I use them. But every one of them has a "raw" escape hatch, and that's where I look first in a code review.

IntermediateWhat is NoSQL injection, and how does it happen in an Express app?

So in MongoDB, query operators are just object keys like $gt or $ne. If I pass req.body values straight into a query, an attacker can send an object where I expected a string, and change the meaning of the query.

// attacker's JSON body:
// { "email": "admin@site.com", "password": { "$ne": "wrong" } }

const user = await User.findOne({
  email: req.body.email,
  password: req.body.password,     // becomes { $ne: "wrong" }, matches any password
});

// also via query strings: ?email[$ne]=x is parsed by qs into an object

What happens here is that the password condition becomes "password is not equal to 'wrong'", which is true for basically everyone. The same trick works through query strings if the query parser turns email[$ne]=x into a nested object. That's the "extended" qs parser, which was Express 4's default; Express 5 switched the default to the simple parser, which closes this path for query strings but not for JSON bodies.

Obviously this particular example also stores plaintext passwords, which is its own problem. But the injection pattern shows up on every field.

The fix is validating types before anything reaches the database:

const Login = z.object({ email: z.email(), password: z.string().min(1).max(200) });
const { email, password } = Login.parse(req.body);   // objects rejected here

Mongoose's sanitizeFilter option or mongo-sanitize can strip $ keys as a second layer. But schema validation at the edge is the real defence, since it fixes the whole class of problems, not just this one.

IntermediateExplain CORS. Does it protect your API?

So CORS is a browser rule, not a server firewall. By default, a page on one origin can't read responses from another origin. CORS headers are how my server tells the browser "it's fine to let app.example.com read this".

app.use(cors({
  origin: (origin, cb) => {
    // allow same-origin/non-browser (no Origin header) and our frontends
    if (!origin || ALLOWED_ORIGINS.has(origin)) return cb(null, true);
    cb(null, false);
  },
  credentials: true,        // allow cookies; requires a specific origin, not "*"
}));

For "non-simple" requests, like a PUT or a JSON POST with custom headers, the browser first sends an OPTIONS preflight asking permission. The cors package answers that.

Now, does it protect the API? Not really. curl, Postman or an attacker's server ignore CORS completely, because it's enforced only by browsers. What CORS protects is users: it stops a malicious website from reading data from my API using the victim's cookies. Authentication and authorisation still have to be on every route.

Two gotchas I've hit. Reflecting any incoming Origin back with credentials: true is effectively "allow everyone with cookies", which defeats the point. And CORS doesn't stop the request from being sent, only the response from being read, so a simple cross-site form POST still reaches the server. That's why CSRF is a separate concern.

Interviewers look for: "CORS is enforced by the browser" and "it doesn't replace auth". Knowing that * can't be combined with credentials is a common follow-up.

IntermediateWhat is CSRF, and when do you need to worry about it?

So CSRF, cross-site request forgery, is when another site makes the victim's browser send a request to my API, and the browser attaches my cookies automatically. The attacker can't read the response, but they can trigger a state change, like "transfer money" or "change email".

<!-- on evil.com -->
<form action="https://bank.com/transfer" method="POST">
  <input name="to" value="attacker"><input name="amount" value="1000">
</form>
<script>document.forms[0].submit()</script>

So the question is whether my auth rides on cookies. If the client sends a Bearer token in the Authorization header, CSRF isn't a concern, because the browser never adds that header on its own.

If I use cookies, my layers are these. First, SameSite=Lax or Strict on the session cookie, so it isn't sent on cross-site POSTs. That alone handles the example above in modern browsers. Second, never change state on GET, because Lax still sends cookies on top-level GET navigations. Third, for high-value actions, or if I need SameSite=None, a CSRF token: a random value the server issues, which the frontend sends back in a header and the server compares.

A cheap extra check is verifying the Origin header on unsafe methods. The csurf package that tutorials mention is deprecated, so these days I use SameSite plus a double-submit or synchroniser token from a maintained library, or just the Origin check.

AdvancedYou run four instances behind a load balancer. How do you rate limit login attempts properly?

So the default in-memory store doesn't work here. Each instance counts separately, so a limit of 5 becomes 20 in practice, and it resets on every deploy. The counters need to live in a shared store, which is Redis in almost every setup I've seen.

import { rateLimit } from "express-rate-limit";
import { RedisStore } from "rate-limit-redis";

const loginLimiter = rateLimit({
  windowMs: 15 * 60_000,
  limit: 5,
  standardHeaders: "draft-8",
  store: new RedisStore({ sendCommand: (...args) => redis.sendCommand(args) }),
  // key on the account, not only the IP
  keyGenerator: (req) => `login:${String(req.body?.email).toLowerCase()}`,
});

app.post("/auth/login", ipLimiter, loginLimiter, loginHandler);

What I'm doing here is limiting on two keys. Per IP stops one machine hammering many accounts. Per account stops a botnet with thousands of IPs guessing one user's password. Either alone has a gap.

There are a few details I care about. trust proxy must be set correctly, or every request looks like the load balancer's IP. On the per-account limit I'd rather add increasing delays or a CAPTCHA than lock the account hard, because a hard lock lets an attacker lock out any user they like. And I track failures only, so a successful login resets the count.

For a public API, I'd often push coarse per-IP limits to the edge, like the CDN, WAF or API gateway, so abusive traffic never reaches Node. Then I keep the business-aware limits, per account or per API key, in the app with Redis.

Interviewers look for: shared state across instances, per-account plus per-IP keys, and awareness that hard lockouts become a denial-of-service vector.

AdvancedWhat security issues are specific to Node and JavaScript that you'd look for?

So beyond the usual web issues, there are three I think of as distinctly JavaScript: prototype pollution, ReDoS, and supply chain.

Prototype pollution happens when user input can write to __proto__, usually through a naive deep-merge. Then every object in the process inherits the attacker's property.

function merge(target, src) {           // naive deep merge
  for (const k in src) {
    if (typeof src[k] === "object") merge(target[k] ??= {}, src[k]);
    else target[k] = src[k];
  }
}
merge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
({}).isAdmin;   // true, for every object in the process

The fixes are schema validation that rejects unknown keys, skipping __proto__, constructor and prototype in merge code, and using Map or Object.create(null) for user-keyed data.

ReDoS is a regular expression with catastrophic backtracking, like /^(a+)+$/, run on a crafted input. Because Node runs my JavaScript on one thread, one request with a 30-character string can freeze the whole server for seconds. I cap input length before matching, avoid nested quantifiers, and lint for unsafe regexes.

Supply chain is the npm ecosystem itself: typosquatted packages, hijacked maintainer accounts, and malicious postinstall scripts. I commit the lockfile, install with npm ci, run npm audit and Dependabot in CI, and think twice before adding a tiny dependency I could write in ten lines.

And one more: child_process.exec with user input is shell injection. I use execFile or spawn with an argument array instead.

26. Performance

BasicNode runs JavaScript on one thread. How do you use all the CPU cores?

So one Node process runs my JavaScript on one core. The I/O happens off-thread, but request handling, JSON parsing and business logic all share one core. To use more cores I run more processes, and there are three common ways to do it.

# 1. pm2 on a VM: one process per core, auto-restart
pm2 start dist/server.js -i max

# 2. the built-in cluster module (what pm2 uses underneath)
import cluster from "node:cluster";
import os from "node:os";
if (cluster.isPrimary) {
  for (let i = 0; i < os.availableParallelism(); i++) cluster.fork();
  cluster.on("exit", () => cluster.fork());     // replace crashed workers
} else {
  (await import("./server.js")).start();          // workers share the port
}

# 3. containers: one process per container, scale replicas in k8s/ECS

In most places I've worked it's option three: each container runs a single node server.js, and the orchestrator runs as many replicas as we need behind a load balancer. Adding cluster inside a container just duplicates what the orchestrator already does, and it makes memory limits and health checks more confusing.

The important consequence is that every process has its own memory. So the app has to be stateless: sessions in Redis, files in S3, caches in Redis or accepted as per-process. Otherwise user A logs in on process 1, lands on process 2, and is suddenly logged out.

And one clarification I like to make: multiple processes scale throughput. They don't make a single CPU-heavy request any faster. That's what worker threads are for.

IntermediateAn endpoint generates a PDF report and everything else slows down while it runs. What do you do?

So that's the event loop being blocked. Generating the PDF is synchronous CPU work on the main thread, and while it runs, no other request's callbacks can run. Every user waits, not just the one who asked for the report.

I have two options, depending on how long the work takes.

// Option 1: worker thread, for work taking up to a few seconds
import { Worker } from "node:worker_threads";

function renderInWorker(data) {
  return new Promise((resolve, reject) => {
    const w = new Worker(new URL("./render-pdf.worker.js", import.meta.url), { workerData: data });
    w.once("message", resolve);
    w.once("error", reject);
    w.once("exit", (code) => code !== 0 && reject(new Error(`worker exit ${code}`)));
  });
}
// in production I'd use a pool (piscina) instead of a new Worker per request

// Option 2: a job queue, for anything slow or retryable
app.post("/reports", async (req, res) => {
  const job = await reportQueue.add("monthly", { userId: req.user.id });   // BullMQ
  res.status(202).json({ jobId: job.id, statusUrl: `/reports/${job.id}` });
});

For a report that takes 30 seconds, I'd go with the queue. The request returns 202 Accepted immediately, a separate worker process does the rendering, and the client polls or gets notified when it's ready. It also survives restarts and can retry, which a worker thread can't.

In my last project we had exactly this with invoice exports. We moved them to BullMQ, and p99 latency on the rest of the API dropped from around two seconds back to about 80 milliseconds.

Interviewers look for: naming "blocking the event loop" as the cause, and picking worker threads vs a queue based on duration and durability.

IntermediateHow would you add caching to a read-heavy endpoint? What can go wrong?

So my default is cache-aside with Redis. I check the cache, on a miss I load from the database and store it with a TTL, and on writes I delete the key.

async function getProduct(id) {
  const key = `product:v1:${id}`;
  const hit = await redis.get(key);
  if (hit) return JSON.parse(hit);

  const product = await db.product.findUnique({ where: { id } });
  if (product) await redis.set(key, JSON.stringify(product), { EX: 300 });
  return product;
}

async function updateProduct(id, data) {
  const updated = await db.product.update({ where: { id }, data });
  await redis.del(`product:v1:${id}`);        // invalidate after the write
  return updated;
}

There are three problems I plan for. The first is stale data. The TTL bounds it, and deleting on write shortens it, but there's still a small race where a slow reader writes the old value back just after my delete. For product pages that's fine. For balances or permissions, I just don't cache.

The second is a stampede. A hot key expires, and 500 requests miss at once and all hit the database. I handle that with a short lock so only one request refills the key, or by adding random jitter to TTLs so keys don't all expire together.

The third is caching misses. If attackers request IDs that don't exist, every request goes to the database. Caching "not found" for a short time fixes that.

The v1 in the key is a small habit: when the shape of the cached object changes, I bump the version instead of flushing Redis.

IntermediateWhat are the biggest performance wins in a typical Node API?

So in my experience it's almost never Node itself. It's the database, and then how much work each request does. My rough order of value looks like this.

-- 1. an index on what you filter and sort by
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;
--   Seq Scan on orders (rows=2,000,000)  ->  add:
CREATE INDEX CONCURRENTLY idx_orders_user_created ON orders (user_id, created_at DESC);

// 2. N+1: one query per row in a loop
for (const o of orders) o.user = await db.user.findUnique({ where: { id: o.userId } });
// becomes one query
const users = await db.user.findMany({ where: { id: { in: orders.map(o => o.userId) } } });

// 3. independent awaits in sequence
const user = await getUser(id);
const orders = await getOrders(id);                                    // slow
const [user2, orders2] = await Promise.all([getUser(id), getOrders(id)]); // parallel

A missing index is the classic: the query scans two million rows instead of reading twenty. Then N+1 queries, where a list of 50 items makes 51 round trips. Then sequential awaits for work that doesn't depend on each other.

After that come pagination, so nobody returns 10,000 rows, compression for large JSON, caching hot reads, and moving slow side effects like emails to a queue.

The rule I follow is measuring first. I look at p95 and p99 latency per route, and slow query logs or traces, and fix the biggest bar. Most "Node is slow" tickets I've seen turned out to be one unindexed query.

Advancedp99 latency spikes in production but the database looks fine. How do you investigate?

So if the database is healthy and it's the tail that's bad, my first suspect is the event loop being blocked now and then. One slow synchronous task delays every request queued behind it. So I measure event loop delay directly.

import { monitorEventLoopDelay } from "node:perf_hooks";

const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
  logger.info({
    loopP99ms: h.percentile(99) / 1e6,
    loopMaxMs: h.max / 1e6,
    heapUsedMb: process.memoryUsage().heapUsed / 1e6,
  }, "runtime");
  h.reset();
}, 10_000).unref();

If the loop's p99 lines up with the latency spikes, something CPU-bound is running on the main thread. Then I profile. node --cpu-prof, or clinic flame under a load test, shows the hot function. The usual culprits I've found are JSON.stringify on a huge response, a sync crypto or zlib call, a bad regex, or a big Array.sort.

If the loop is fine, I look at other causes. GC pauses show up as a growing heap and long GC in --trace-gc, and often mean a leak. Connection pool exhaustion shows as requests waiting for a DB connection, even though each query is fast. That's "the database looks fine" but the app is queuing. Then there's a slow downstream HTTP call with no timeout, and CPU throttling from a container limit that's too low.

In my last project it was pool exhaustion: the pool size was 10, and one endpoint held a connection across an external API call. Queries were 5 ms, but requests waited seconds for a connection.

Interviewers look for: a method (measure loop delay, profile, check pool/GC) rather than guesses, and knowing that averages hide tail latency.

AdvancedMemory keeps growing until the container is OOM-killed. How do you find the leak?

So first I confirm it's a real leak and not just a large but stable heap. If heapUsed climbs steadily under constant load and never comes back down after GC, something is holding references it shouldn't.

Then I compare heap snapshots. I take one after warm-up, run load for a while, take another, and look at what grew.

# run with the inspector, or trigger snapshots on a signal
node --inspect server.js
node --heapsnapshot-signal=SIGUSR2 server.js   # kill -USR2 <pid> writes a .heapsnapshot

# in Chrome DevTools > Memory: load both, use "Comparison" view,
# sort by "# Delta" / "Size Delta", then follow the retainers

The retainers path tells me who is holding the objects, and that's usually the answer. The leaks I've actually seen, in order of how often:

const cache = new Map();                   // 1. unbounded module-level cache
app.get("/u/:id", async (req, res) => {
  cache.set(req.params.id, await load(req.params.id));   // grows forever
});

emitter.on("data", handler);               // 2. listener added per request, never removed
setInterval(poll, 1000);                   // 3. timers never cleared
// 4. closures capturing req/res stored in long-lived arrays

For the cache, I switch to an LRU with a max size and TTL, like lru-cache. For listeners, Node's "MaxListenersExceededWarning" is often an early hint that I ignored.

One practical point: I also set --max-old-space-size a bit below the container's memory limit. Then Node reports a heap error I can see, instead of the kernel silently killing the process.

27. Logging & Debugging

BasicWhy use a logger like pino instead of console.log?

So the main reason is structure. console.log gives me free text, which is fine locally but useless when I'm searching a million lines in production. A logger like pino writes one JSON object per line with consistent fields, so I can filter by userId, requestId or level in Datadog, Loki or CloudWatch.

import pino from "pino";

const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",
  redact: ["req.headers.authorization", "*.password", "*.token"],
});

// free text: can't query this
console.log("payment failed for user " + userId + " order " + orderId);

// structured
logger.error({ userId, orderId, err }, "payment failed");
// {"level":50,"time":1767225600000,"userId":7,"orderId":"o_91","err":{"type":"Error","message":"card declined","stack":"..."},"msg":"payment failed"}

What I get on top of that: log levels, so I can turn on debug for one service without a redeploy; proper error serialisation with the stack; redaction of secrets; and child loggers that carry context like the request ID automatically.

Performance matters too. pino is built to be cheap, and in production the formatting and shipping happen outside the main path. console.log to a terminal or pipe can be synchronous, which blocks the event loop under heavy logging.

Locally I pipe through pino-pretty so it's readable. In production it stays raw JSON, since that's what the log platform wants.

IntermediateHow do you trace one request through all your logs?

So I give every request an ID at the edge and put it on every log line that request produces. If a gateway or load balancer already sets x-request-id, I reuse it, so the ID matches across services.

Passing req down into every service function just to log gets ugly, though. So I use AsyncLocalStorage, which keeps a context alive across all the awaits for that request.

import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";

export const als = new AsyncLocalStorage();

app.use((req, res, next) => {
  const requestId = req.get("x-request-id") ?? randomUUID();
  res.set("x-request-id", requestId);
  const log = logger.child({ requestId });
  als.run({ requestId, log }, next);
});

// anywhere deep in the code, no req needed
export const log = () => als.getStore()?.log ?? logger;

async function chargeCard(orderId) {
  log().info({ orderId }, "charging card");
}

What happens is that als.run starts a context, and every async operation started inside it, including promises, timers and DB callbacks, can read that same store. So when two requests interleave, each log line still has the right requestId.

I also return the ID in the response header. When a customer reports an error, support can give me the ID, and one search shows the whole journey. Across services I'd move to OpenTelemetry, which propagates a traceparent header and gives me distributed traces, with the trace ID added to logs the same way.

Interviewers look for: request IDs plus a way to propagate them without threading req everywhere. AsyncLocalStorage or OpenTelemetry are strong answers.

IntermediateWhat should and shouldn't go in your logs? How do you pick log levels?

So I log facts I'd need to debug an incident, and never anything that would be a problem if the logs leaked. Logs get copied to lots of places and read by lots of people, so I treat them as semi-public.

Things I never log: passwords, tokens, API keys, session cookies, full card numbers, and generally not raw request bodies on auth or payment routes. I use redaction in the logger as a safety net, because someone will eventually write logger.info({ body: req.body }).

const logger = pino({
  redact: {
    paths: ["req.headers.authorization", "req.headers.cookie", "*.password", "*.cardNumber"],
    censor: "[REDACTED]",
  },
});

For levels, my rule is based on who needs to act:

  • error: something failed and someone may need to look. It should be rare enough to alert on.
  • warn: unusual but handled, like a retry that succeeded or a deprecated endpoint being called.
  • info: business events and lifecycle, like "server started", "order placed" or one line per request.
  • debug: detail for development, off in production by default.

One gotcha I've seen: logging 4xx errors at error level. A user typing a wrong password isn't an incident, and it floods the alerts until everyone ignores them. So client errors are info or warn, and 5xx are error.

IntermediateHow do you debug a Node app beyond console.log?

So for anything non-trivial I use the real debugger. Node has the V8 inspector built in, and I attach VS Code or Chrome DevTools to it.

node --inspect server.js          # listen for a debugger on 127.0.0.1:9229
node --inspect-brk server.js      # same, but pause on the first line
node --watch --env-file=.env server.js   # restart on change, load .env, no extra deps

# in a test
node --inspect-brk --test test/orders.test.js

Then I set breakpoints, including conditional ones like "only when userId === 42", step through the code, and inspect the whole scope at that point, including closures. It's much faster than adding a log line and restarting five times. In VS Code, the "JavaScript Debug Terminal" attaches to any Node process I start from it, so I don't need a launch config.

A few other tools I reach for. The debugger; statement pauses there when an inspector is attached. NODE_DEBUG=http,net prints Node's internal debug output for those modules. --trace-warnings and --trace-uncaught give stack traces for warnings and uncaught errors. And for async code, DevTools shows async stack traces, so I can see which await chain led to the current line.

One safety note: --inspect binds to localhost by default, and I'd never expose it on 0.0.0.0 in production, because an open inspector port means remote code execution.

AdvancedHow do you handle uncaughtException and unhandledRejection in production?

So my view is that these handlers are for logging and a clean exit, not for recovery. After an uncaught exception, the process is in an unknown state: a transaction half-done, a lock never released. The safe move is to log it, stop taking traffic, and let the orchestrator start a fresh process.

function crash(kind, err) {
  logger.fatal({ err, kind }, "process crashing");
  server.close();                                  // stop accepting new connections
  setTimeout(() => process.exit(1), 5000).unref(); // give in-flight requests a moment
}

process.on("uncaughtException", (err) => crash("uncaughtException", err));
process.on("unhandledRejection", (reason) => crash("unhandledRejection", reason));

What happens here is that I get one structured fatal log with the stack, which is the thing I most need later, and then a non-zero exit. Since Node 15, an unhandled rejection already crashes the process by default, which I think is the right default. The handler just makes sure it's logged properly first.

The anti-pattern I push back on is process.on("uncaughtException", () => {}) to "keep the server up". It hides bugs and leaves the app running in a broken state, like leaking connections, until it fails in a much stranger way.

So the real safety comes from elsewhere: running several instances so one crash isn't an outage, auto-restart from Kubernetes, ECS or pm2, and fixing the root cause. In Express, most of these come from async handlers. Express 5 forwards rejected promises to the error middleware; on Express 4 I wrap handlers so they don't become unhandled rejections in the first place.

Interviewers look for: "log and exit, let the supervisor restart", not "catch and continue".

28. Testing

BasicWhat's the difference between unit and integration tests for an API? What do you test with each?

So a unit test checks one piece in isolation, usually a service function with its dependencies faked. An integration test checks pieces working together, and for an API that means a real HTTP request going through routing, middleware, validation and the database.

// unit: pure business rule, no HTTP, no DB
test("applies 10% discount over 100", () => {
  assert.equal(priceWithDiscount(200), 180);
  assert.equal(priceWithDiscount(50), 50);
});

// integration: the real route, via supertest
test("POST /orders rejects an empty cart", async () => {
  const res = await request(app)
    .post("/orders")
    .set("Authorization", `Bearer ${tokenFor(alice)}`)
    .send({ items: [] });

  assert.equal(res.status, 400);
  assert.match(res.body.error, /cart/i);
});

What I use each one for: unit tests for logic with lots of branches, like pricing, permissions or date handling, because they're fast and pinpoint the failure. Integration tests for everything that's about wiring: is the middleware in the right order, does validation reject bad input, does the route return the right status, does the query actually work against the real schema.

For an Express API, I find integration tests give the most confidence per test. Most bugs I've shipped weren't in a pure function. They were a missing await, a middleware in the wrong order, or a query that worked on the mock but not on Postgres.

These days the runner can be Vitest, Jest or Node's built-in node:test. For a backend without a build step, node --test is honestly enough.

BasicWhy do people split app.js and server.js?

So app.js builds and exports the Express app: middleware, routes and error handlers. server.js is the only file that calls listen, connects to the database, and handles shutdown. The split exists mainly for testing.

// app.js
import express from "express";
export function createApp({ db }) {
  const app = express();
  app.use(express.json());
  app.use("/api/users", usersRouter(db));
  app.use(errorHandler);
  return app;
}

// server.js
const db = await connectDb(process.env.DATABASE_URL);
const app = createApp({ db });
const server = app.listen(process.env.PORT ?? 3000);
process.on("SIGTERM", () => shutdown(server, db));

// users.test.js
const app = createApp({ db: testDb });
const res = await request(app).get("/api/users/1");   // no port, no listen

What happens in the test is that supertest takes the app, binds it to an ephemeral port internally, and closes it afterwards. So tests don't fight over port 3000, can run in parallel, and don't leave open handles that make the runner hang at the end.

I like going one step further, like above, and exporting a createApp factory that takes its dependencies. Then a test can pass in a test database or a fake email sender without module mocking tricks.

It also keeps side effects out of imports. Importing app.js shouldn't open a database connection or start a server. That makes the code easier to reuse, for example in a serverless handler.

IntermediateDo you mock the database in tests, or use a real one?

So for integration tests I use a real database of the same engine and version as production, and I only mock things outside my control, like payment providers or email.

The reason is that mocking the database mostly tests that my mock agrees with my assumptions. I've seen a fully green test suite ship a query that failed in production because of a unique constraint, a case-sensitive comparison, and a JSON column behaving differently. None of those exist in a mock.

// vitest globalSetup with Testcontainers
import { PostgreSqlContainer } from "@testcontainers/postgresql";

export default async function setup() {
  const pg = await new PostgreSqlContainer("postgres:17").start();
  process.env.DATABASE_URL = pg.getConnectionUri();
  await runMigrations(process.env.DATABASE_URL);
  return () => pg.stop();
}

// per test: isolate with a transaction you roll back, or truncate
beforeEach(async () => { await db.query("TRUNCATE users, orders RESTART IDENTITY CASCADE"); });

What this gives me is a throwaway Postgres in Docker, with the real migrations applied, so the schema in tests is exactly the schema in production. Each test starts from a clean state.

It's slower than mocks, a few seconds of startup, so I keep the balance sensible: lots of fast unit tests for business logic with the repository faked, and integration tests with the real database for routes and queries. In CI the container starts once per run, not per test.

Interviewers look for: a clear reason not to mock the DB, plus how you keep tests isolated (transactions or truncation) so they can run in any order.

IntermediateHow do you test code that calls external APIs or depends on time?

So for external HTTP calls I intercept at the network layer rather than mocking my own functions. That way my real client code, including headers, URL building and error handling, actually runs.

import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";

const server = setupServer(
  http.post("https://api.stripe.com/v1/charges", () =>
    HttpResponse.json({ error: { message: "Card declined" } }, { status: 402 }))
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterAll(() => server.close());

test("declined card returns 402 and does not create the order", async () => {
  const res = await request(app).post("/checkout").send(validCart);
  expect(res.status).toBe(402);
  expect(await db.order.count()).toBe(0);
});

What I like here is onUnhandledRequest: "error". If code makes a real outbound call I didn't expect, the test fails instead of quietly hitting a live API. I test the unhappy paths the most: timeouts, 500s, malformed JSON, because that's where the bugs are.

For time, I use fake timers, so a test about "tokens expire after 15 minutes" doesn't wait 15 minutes.

vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const token = issueToken(user);
vi.advanceTimersByTime(16 * 60 * 1000);
expect(() => verifyToken(token)).toThrow(/expired/);
vi.useRealTimers();

With node:test, mock.timers.enable() does the same. The general idea is to control every source of non-determinism, like the network, the clock and randomness, so a test gives the same result on every run.

IntermediateHow do you test that authorisation actually works?

So I write tests from the attacker's point of view: log in as one user and try to touch another user's data. Most API test suites only test "the owner can do it", which says nothing about whether a stranger can.

describe("GET /invoices/:id", () => {
  test("owner can read their invoice", async () => {
    const res = await asUser(alice).get(`/invoices/${aliceInvoice.id}`);
    expect(res.status).toBe(200);
  });

  test("another user gets 404, not the data", async () => {
    const res = await asUser(bob).get(`/invoices/${aliceInvoice.id}`);
    expect(res.status).toBe(404);
    expect(res.body).not.toHaveProperty("amount");
  });

  test("no token gets 401", async () => {
    const res = await request(app).get(`/invoices/${aliceInvoice.id}`);
    expect(res.status).toBe(401);
  });

  test("non-admin cannot use the admin route", async () => {
    const res = await asUser(bob).delete(`/admin/users/${alice.id}`);
    expect(res.status).toBe(403);
  });
});

What these cover is the full matrix: no identity, wrong identity, wrong role, and the right identity. The asUser helper just signs a token for that fixture user, so the tests stay short.

In my last project we made this systematic. We had a table of every route with the roles allowed, and a generated test that called every route as every role and checked for 2xx or 401/403/404. When someone added a new route without auth, CI failed straight away, because the route wasn't in the table.

AdvancedYour API test suite is slow and flaky. How do you fix it?

So I treat flaky and slow as separate problems, and flaky comes first, because a suite nobody trusts gets ignored.

For flakiness, the usual causes I find are shared state between tests, real time, real network and ordering assumptions.

// flaky: depends on what other tests inserted, and on list order
const res = await request(app).get("/users");
expect(res.body[0].name).toBe("Ada");

// stable: create what you need, assert on what you created
const ada = await factory.user({ name: "Ada" });
const res = await request(app).get(`/users/${ada.id}`);
expect(res.body.name).toBe("Ada");

So each test creates its own data with factories, assertions don't depend on global ordering, time is faked, outbound HTTP is intercepted with unhandled requests failing, and every test awaits its async work, because a floating promise lets an assertion run after the test has "passed". I run the suite with random ordering and repeat counts to shake out the rest.

For speed, I first measure which files are slow. Then the common wins are: starting the database container once per run instead of per file; resetting data with a rolled-back transaction or truncation instead of re-running migrations; running files in parallel with a separate schema or database per worker; and moving branch-heavy logic down to fast unit tests, so integration tests only cover wiring and the key paths.

In my last project, those changes took the suite from about 11 minutes to under 3, and we quarantined the two tests that were still flaky with a ticket each, instead of adding retries to the whole suite.

Interviewers look for: test isolation (own data, no ordering), deterministic time and network, and parallelism with per-worker databases.

29. Deployment

BasicWhat does NODE_ENV=production actually do, and how do you manage config and secrets?

So NODE_ENV is just an environment variable, and Node itself ignores it. But libraries read it. Express caches view templates and stops sending stack traces in its default error responses. Many packages skip dev-only checks. And npm ci leaves out devDependencies when NODE_ENV=production is set. Forgetting it can make an app noticeably slower and leak stack traces to users.

For config in general, I follow the twelve-factor idea: the same build artefact everywhere, with config coming from environment variables. And I validate it at startup, so a missing variable fails the deploy instead of the first request that needs it.

// config.js
import { z } from "zod";

const Env = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.url(),
  JWT_SECRET: z.string().min(32),
});

export const config = Env.parse(process.env);   // throws on boot if anything's wrong

Locally I use a .env file loaded with node --env-file=.env, and it's in .gitignore, with a committed .env.example listing the names. In production, secrets come from the platform's secret manager, like AWS Secrets Manager or Kubernetes secrets, injected as environment variables. Never baked into the Docker image, because anyone who can pull the image can read its layers.

And if a secret ever lands in git, I rotate it. Deleting the commit isn't enough.

BasicWalk me through a good Dockerfile for a Node API.

So the goals are a small image, fast rebuilds, and not running as root. I use a multi-stage build.

# ---- build stage ----
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci                       # all deps, needed to build
COPY . .
RUN npm run build && npm prune --omit=dev

# ---- runtime stage ----
FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --chown=node:node package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Let me walk through the choices. Copying the package files before the source means the npm ci layer is cached until dependencies change, so a code-only change rebuilds in seconds. npm ci installs exactly what's in the lockfile. The runtime stage only gets the built output and production dependencies, with no compilers, TypeScript or tests, which often halves the image size. USER node means a compromise doesn't give the attacker root inside the container.

The CMD matters too. I run node directly, not npm start. npm doesn't reliably forward SIGTERM to the child process, so graceful shutdown silently never happens, and the container gets SIGKILLed after the grace period.

And there's always a .dockerignore with node_modules, .env and .git, so I don't copy local modules built for macOS, or secrets, into the image.

Interviewers look for: layer caching order, npm ci, non-root user, and running node directly so signals arrive.

IntermediateWhat's the difference between a liveness check and a readiness check?

So they answer different questions, and the orchestrator does different things with them. Liveness asks "is this process stuck?", and if it fails, the container gets restarted. Readiness asks "can this instance serve traffic right now?", and if it fails, the instance is taken out of the load balancer but left running.

let shuttingDown = false;

app.get("/livez", (req, res) => res.json({ ok: true }));   // cheap, no dependencies

app.get("/readyz", async (req, res) => {
  if (shuttingDown) return res.status(503).json({ ready: false, reason: "shutting down" });
  try {
    await db.query("SELECT 1");
    res.json({ ready: true });
  } catch {
    res.status(503).json({ ready: false, reason: "db unreachable" });
  }
});

The mistake I've seen cause a real outage is checking the database in the liveness probe. The database has a blip, every pod fails liveness, Kubernetes restarts all of them at once, and they all reconnect together and hammer the database as it recovers. Restarting never fixes a dependency that's down. So liveness stays dependency-free, and dependency checks go in readiness.

Readiness is also useful during startup, like while warming a cache or running a connection check, and during shutdown. That's why I flip shuttingDown on SIGTERM, so the load balancer stops routing to the instance before I close the server.

IntermediateHow do you deploy a new version without dropping requests?

So I do a rolling deploy, where new instances come up before old ones go down, combined with a graceful shutdown in the app. Both halves are needed.

The sequence in Kubernetes, which is what I've used most, goes like this:

1. New pod starts; it gets traffic only once /readyz returns 200
2. Old pod receives SIGTERM
3. App: mark not-ready, stop accepting new connections (server.close())
4. App: let in-flight requests finish, close DB pool, exit(0)
5. If it's still running after terminationGracePeriodSeconds (30s): SIGKILL

# deployment.yaml
strategy:
  rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }
lifecycle:
  preStop: { exec: { command: ["sleep", "5"] } }   # let endpoints update first

The preStop sleep is the non-obvious bit. Removing a pod from the load balancer happens asynchronously, so for a few seconds after SIGTERM, traffic can still arrive. Without the delay, those requests hit a server that's already closed and fail.

A few other things bite people. Keep-alive connections: in Node 19+, server.close() closes idle keep-alive sockets, but on older versions I call server.closeIdleConnections() myself. Long-running work: requests that take longer than the grace period need to be jobs in a queue, not HTTP requests. And the app must actually receive the signal, which is why the container runs node directly.

The last part is database changes. The old and new versions run side by side during the rollout, so migrations have to be backwards compatible.

AdvancedHow do you ship a database schema change without downtime, like renaming a column?

So the problem is that during a rolling deploy, old and new code run at the same time against the same database. If I rename name to full_name in one step, the old pods start failing the moment the migration runs. So I use expand and contract, spread over several deploys.

Deploy 1  (expand)   ALTER TABLE users ADD COLUMN full_name text;
                     app writes BOTH name and full_name, still reads name
Backfill             UPDATE users SET full_name = name WHERE full_name IS NULL
                     (in batches of ~5k rows, not one giant UPDATE)
Deploy 2             app reads full_name, still writes both
Deploy 3             app stops writing name
Deploy 4  (contract) ALTER TABLE users DROP COLUMN name;

What this means is that at every step, both the currently running version and the one before it work against the schema. So I can roll back any single deploy without breaking anything.

There are a few Postgres-specific things I watch. I create indexes with CREATE INDEX CONCURRENTLY, so the table isn't locked for writes. I add NOT NULL constraints as NOT VALID first, then validate separately. I set a short lock_timeout on migrations, so one waiting on a lock fails fast instead of queuing every query behind it. And I backfill in batches, so I don't hold one huge transaction.

Operationally, migrations run as a separate step before the rollout, like a Kubernetes Job or a CI step, not from every app instance on boot, where four pods would race to apply them.

It's more work than a single rename. But on a busy table, the single-step version really does take the site down.

Interviewers look for: expand/contract, "old and new code run together", and lock awareness (concurrent indexes, lock_timeout, batched backfills).

AdvancedShould you use pm2 or the cluster module inside a Docker container?

So usually no. In a container platform, I run one Node process per container and let the orchestrator handle what pm2 would otherwise do: restarting on crash, scaling to more copies, and spreading them across machines.

Concernpm2 on a VMKubernetes / ECS
Restart on crashpm2Container restart policy
Use N cores-i maxN replicas, or 1 CPU per pod
HealthLimitedLiveness / readiness probes
Logspm2 log filesstdout collected by the platform

Running pm2 or cluster inside the container adds a second supervisor that hides things from the first. If one worker is stuck but the pm2 parent is fine, the liveness probe might hit a healthy worker and never restart the bad one. Memory limits become harder to reason about, since four workers share one limit, and a single OOM kills all of them together. And signal handling gets another layer where SIGTERM can go missing.

There are cases where I would. On a plain VM, or a PaaS that gives me one big container and bills per container, pm2 or cluster is a reasonable way to use the cores. The same goes when the per-container overhead is significant.

One related detail: if the process might spawn children, or I just want correct signal forwarding and zombie reaping, I run it with docker run --init, or tini as the entrypoint, because a Node process running as PID 1 doesn't get the kernel's default signal handling.

Practice

Predict the Output

BasicWhat does this log? (EventEmitter: when do listeners run?)
const EventEmitter = require('events');
const e = new EventEmitter();

e.on('greet', (n) => console.log('listener 1', n));
console.log('before emit');
e.emit('greet', 'Ada');
e.on('greet', (n) => console.log('listener 2', n));
console.log('after emit');
e.emit('greet', 'Bob');

Output: before emit, listener 1 Ada, after emit, listener 1 Bob, listener 2 Bob

So the key thing is that emit is synchronous. It's just a loop that calls each registered listener, in the order they were added, right there on the current call stack. Nothing is queued on the event loop. That's why listener 1 Ada prints before after emit.

Listener 2 is registered after the first emit, so it misses Ada entirely. Events aren't buffered, and a late subscriber doesn't get a replay. On the second emit both listeners are there, so both run, in registration order.

A related gotcha: emit('error') with no error listener throws synchronously, and without a try/catch that crashes the process. So every emitter I create gets an 'error' listener. And because listeners run synchronously, a slow listener blocks whoever called emit. If I want async behaviour, I defer inside the listener with setImmediate or queueMicrotask.

Interviewers look for: "emit is synchronous" and "no replay for late listeners". The 'error' event special case is a common follow-up.

BasicWhat does this log? (require caching)
// counter.js
console.log('loading counter.js');
let count = 0;
module.exports = { inc: () => ++count, get value() { return count; } };

// main.js
const a = require('./counter');
const b = require('./counter.js');
a.inc(); a.inc(); b.inc();
console.log(a === b, a.value, b.value);

delete require.cache[require.resolve('./counter')];
const c = require('./counter');
console.log(c === a, c.value);

Output: loading counter.js, true 3 3, loading counter.js, false 0

So the first require resolves the path to an absolute filename, runs the module once, and stores its module.exports in require.cache under that filename. The second call, even with .js added, resolves to the same file, so it gets the cached object back without running the file again. That's why "loading" prints once, and a === b is true. They're the same object with the same closure, so three increments give 3 for both.

When I delete the cache entry, the next require runs the file again from scratch. That gives a brand-new object with its own count of 0, and the old a is untouched.

This caching is why a module works as a singleton in Node, like a DB pool created in db.js that everyone shares. The gotcha is that the cache key is the resolved path, so two copies of a package in different node_modules folders are two separate "singletons". ES modules are cached the same way, but there's no public cache object to delete from.

BasicWhat does the client receive? (middleware order and express.json())
const app = express();

app.post('/users', (req, res) => res.json({ body: req.body ?? 'undefined' }));
app.use(express.json());
app.post('/items', (req, res) => res.json({ body: req.body }));

// both requests send: Content-Type: application/json, body {"name":"Ada"}

Output: /users returns {"body":"undefined"}, and /items returns {"body":{"name":"Ada"}}

So Express runs middleware and routes in the exact order they were registered. When the /users request arrives, the route handler matches first and sends a response. The express.json() middleware is further down the stack, so it never runs for that request, and nobody parsed the body. req.body is undefined.

For /items, the request passes through express.json() first, which reads the stream, parses the JSON and sets req.body, then reaches the route.

I've hit this exact bug when someone added a router near the top of app.js "to keep it with the other health routes", above the body parser. The symptom was validation saying "name is required" when it was clearly in the request. And req.body is undefined whenever no parser has run, in both Express 4 and 5, which surprises people who expect {}.

So my habit is a fixed order in app.js: security and parsers, then request ID and logging, then routes, then the 404 handler, then the error handler last.

IntermediateWhat does this log? (nextTick, Promise, setTimeout, setImmediate from the main module)
// main.js, run as CommonJS: node main.js
console.log('A');
setTimeout(() => console.log('B timeout'), 0);
setImmediate(() => console.log('C immediate'));
Promise.resolve().then(() => console.log('D promise'));
process.nextTick(() => console.log('E nextTick'));
console.log('F');

Output: A, F, E nextTick, D promise, then B timeout and C immediate in either order

OK, so first the synchronous code runs top to bottom, which gives A and F. Everything else was only scheduled.

When the main script finishes, Node drains the process.nextTick queue before the promise microtask queue. So E comes before D. Both run before the event loop even starts its first iteration.

Then the loop starts, and this is the part people get wrong. setTimeout(fn, 0) really means 1 ms. Whether that 1 ms has passed when the loop checks the timers phase depends on how fast the process started. If it has, B runs in the timers phase, then C in the check phase. If not, the loop moves on, runs C in the check phase, and B on the next iteration. When I ran it 20 times I got timeout first 16 times and immediate first 4 times.

One more twist: if I save this as main.mjs, D prints before E. In an ES module, the top-level code itself runs as part of a promise job, so the microtask queue drains before Node gets back to the nextTick queue.

Interviewers look for: "nextTick before promises" and the honest answer that timeout vs immediate is non-deterministic from the main module.

IntermediateWhat does this log? (the same timers, inside an I/O callback)
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
  process.nextTick(() => console.log('nextTick'));
});

Output: nextTick, immediate, timeout, and this time it's always that order.

So the readFile callback runs in the poll phase of the event loop. When that callback returns, Node drains the nextTick queue straight away, so nextTick comes first.

Then the loop continues from where it is. After poll comes the check phase, which is where setImmediate callbacks run. So immediate fires in this same iteration. The timers phase only comes round again on the next iteration, so timeout is last.

That's the difference from the previous question. From the main module, the loop hasn't started yet, so whether the timer is due is a race. From inside an I/O callback, the phase order decides it: poll, then check, then around to timers.

The practical version of this: if I'm inside an I/O callback and want something to run "right after this, but after pending I/O", setImmediate is the predictable choice. setTimeout(fn, 0) is at least 1 ms and lands in a later iteration.

IntermediateWhat happens to this request in Express 4 vs Express 5?
const app = express();

app.get('/user', async (req, res) => {
  throw new Error('db down');
});

app.use((err, req, res, next) => {
  console.log('error handler:', err.message);
  res.status(500).json({ error: 'Internal' });
});

Output: Express 4: the error handler never runs, the rejection is unhandled, and on Node 15+ the process crashes with Error: db down. The client's request hangs, then the connection drops. Express 5: error handler: db down, and the client gets a 500 with {"error":"Internal"}.

So the handler is an async function, which means the throw doesn't throw synchronously. It turns into a rejected promise that the function returns.

Express 4 calls the handler inside a try/catch, which only catches synchronous throws, and it ignores the return value. So nobody handles the rejected promise. On modern Node an unhandled rejection is fatal by default, so the whole server goes down, taking every other in-flight request with it. If someone had added an unhandledRejection listener to keep it alive, the process would survive, but this request would hang until the client timed out, since nothing ever sends a response.

Express 5 checks whether the handler returned a promise, and if it rejects, calls next(err) for me. So the error flows to my error middleware as I'd expect.

On Express 4 the fix is try/catch with next(err) in every async handler, a small asyncHandler wrapper, or the express-async-errors patch. On Express 5 I just throw.

Interviewers look for: why the error is missed (async throw means a rejected promise, which Express 4 ignores) and the fact that Express 5 fixes it.

IntermediateWhat gets logged, and what does the client get? (a missing return)
app.use((req, res, next) => {
  console.log('1 before');
  next();
  console.log('1 after');
});

app.get('/me', (req, res) => {
  if (!req.headers.authorization) {
    res.status(401).json({ error: 'No token' });   // no return!
  }
  console.log('2 handler continues');
  res.json({ id: 7 });
});

app.use((err, req, res, next) => {
  console.log('3 error handler:', err.code, 'headersSent =', res.headersSent);
});

// request: GET /me with no Authorization header

Output: 1 before, 2 handler continues, 3 error handler: ERR_HTTP_HEADERS_SENT headersSent = true, 1 after. The client gets 401 {"error":"No token"}.

So res.json sends the response, but it doesn't stop the function. It's just a method call. Without a return, execution falls through, logs "2 handler continues", and calls res.json a second time. By then the headers are already on the wire, so Node throws ERR_HTTP_HEADERS_SENT, "Cannot set headers after they are sent to the client".

That throw is synchronous inside a handler, so Express catches it and passes it to the error middleware. That's line 3. The error handler can't send anything useful, because a response is already out. That's why well-written error handlers check res.headersSent and delegate to next(err) in that case.

And "1 after" prints last, because next() in the first middleware is an ordinary synchronous call. Everything downstream runs inside it, and then control comes back. Code after next() isn't "after the response" in any reliable sense, and for async handlers it would run before they finish.

The fix is return res.status(401).json(...). I write return in front of every early response, as a habit.

IntermediateWhat does this log? (stream backpressure)
const { Writable } = require('stream');

const slow = new Writable({
  highWaterMark: 4,                                     // bytes
  write(chunk, enc, cb) { setTimeout(cb, 10); },        // slow consumer
});

for (let i = 1; i <= 3; i++) {
  const ok = slow.write('ab');
  console.log(`write ${i}:`, ok, 'buffered =', slow.writableLength);
}
slow.once('drain', () => console.log('drain, buffered =', slow.writableLength));

Output: write 1: true buffered = 2, write 2: false buffered = 4, write 3: false buffered = 6, drain, buffered = 0

So write() returns a boolean that means "is the internal buffer still below highWaterMark?". After the first write, 2 bytes are buffered, below 4, so true. After the second, it's 4, which reaches the limit, so false. That's the stream saying "please stop until I emit drain".

The important part is write 3. It still succeeds, and the buffer grows to 6. false is advice, not an error, and the data is never rejected. So if a producer ignores it and keeps writing, say reading a 5 GB file faster than a slow client can download it, the whole thing piles up in memory.

Once the slow consumer has worked through everything, the buffer empties and drain fires. A well-behaved producer pauses when write returns false and resumes on drain.

In practice I almost never do this by hand. pipeline() from stream/promises handles backpressure, and also errors and cleanup, across the whole chain.

AdvancedWhat does this log? (nextTick and promises scheduling each other)
// CommonJS
Promise.resolve().then(() => {
  console.log('p1');
  process.nextTick(() => console.log('tick inside p1'));
}).then(() => console.log('p2'));

process.nextTick(() => {
  console.log('t1');
  Promise.resolve().then(() => console.log('promise inside t1'));
});

Output: t1, p1, promise inside t1, p2, tick inside p1

OK, so after the main script, the nextTick queue runs first: t1. It queues a microtask, "promise inside t1", behind p1, which was already waiting.

Then Node drains the promise microtask queue, and it drains it completely, including anything added while draining. So p1 runs. It schedules a nextTick, but that has to wait, because we're inside the microtask drain. Then "promise inside t1" runs. Then p2, which became ready when p1's callback returned, and it's still the same drain.

Only when the microtask queue is empty does Node check the nextTick queue again, and then "tick inside p1" runs.

So the rule "nextTick always beats promises" is only true at the boundary where Node picks which queue to drain. Once it's inside a promise drain, a newly queued nextTick waits until that drain is finished.

The practical takeaway I'd give: code shouldn't depend on this ordering. If one step must happen after another, chain it explicitly with await.

Interviewers look for: the idea that each queue is drained completely, including newly added items, before Node switches queues.

AdvancedWhat does the client get? (next() called twice)
app.use((req, res, next) => {
  console.log('A');
  next();
  next();        // oops: e.g. one call in a callback, one at the end
});

app.get('/x', async (req, res) => {
  console.log('B route');
  await new Promise((r) => setTimeout(r, 50));   // pretend DB call
  try { res.send('hi'); }
  catch (e) { console.log('route threw:', e.code); }
});

// request: GET /x

Output: A, B route, then the client gets a 404 HTML page ("Cannot GET /x"), and after that the server logs route threw: ERR_HTTP_HEADERS_SENT. Same in Express 4 and 5.

So the first next() moves to the route. The route logs "B route", hits the await, and returns a pending promise, so control comes back to the middleware straight away.

Now the second next() runs. The router's position has already moved past the /x route, and there's nothing left in the stack. So Express falls through to its final handler, which is the default 404. It sends "Cannot GET /x" immediately.

Fifty milliseconds later the route wakes up and calls res.send, but the response is already finished. That throws "headers already sent".

The interesting part is that if the route were fully synchronous, the bug would be invisible. The route would respond first, and the second next() would find the headers already sent and quietly do nothing useful. So this kind of bug often appears only once someone makes a handler async. The fix is making sure each middleware path calls next() exactly once, usually with return next().

Machine Coding

BasicImplement promisify from scratch.

So promisify takes a Node-style function, one whose last argument is a callback (err, result), and returns a function that returns a promise instead. Let me write this out.

function promisify(fn) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      fn.call(this, ...args, (err, ...results) => {
        if (err) return reject(err);
        resolve(results.length > 1 ? results : results[0]);
      });
    });
  };
}

// usage
const fs = require('fs');
const readFile = promisify(fs.readFile);

const text = await readFile('./config.json', 'utf8');
await readFile('/nope').catch((e) => console.log(e.code));   // ENOENT

OK, so walking through it: I return a regular function, not an arrow, so this is whatever the caller used. That matters for methods like client.query, where fn.call(this, ...) keeps the method bound to its object.

I call the original with the caller's arguments plus my own callback at the end. In that callback, a truthy err rejects. Otherwise I resolve with the result. If the callback got several results, like (err, a, b), I resolve with an array, since a promise can only carry one value.

The real util.promisify does a bit more. It respects a util.promisify.custom symbol, which is how setTimeout gets a sensible promise version, and some core functions define their own named results. In real code I'd use util.promisify, or better, the built-in promise APIs like fs/promises and timers/promises.

Interviewers look for: preserving this, the error-first convention, and not calling both resolve and reject.

BasicWrite an async error wrapper for Express route handlers.

So the problem, on Express 4, is that a rejected promise from an async handler never reaches the error middleware. I want a wrapper that catches any rejection and forwards it to next.

const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve()
    .then(() => fn(req, res, next))
    .catch(next);
};

// usage
router.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await userService.getById(req.params.id);   // may throw NotFoundError
  res.json(user);
}));

// one place handles everything
app.use((err, req, res, next) => {
  if (res.headersSent) return next(err);
  const status = err.status ?? 500;
  res.status(status).json({ error: status < 500 ? err.message : 'Internal server error' });
});

OK, so the reason I start with Promise.resolve().then(...) instead of just fn(...).catch(next) is that it covers two cases. If fn is async and rejects, .catch(next) gets it. And if someone wraps a non-async function that throws synchronously, calling it inside .then turns the throw into a rejection too. With the shorter version, a sync throw would escape before .catch is attached, though Express would still catch that one itself.

.catch(next) passes the error as the first argument, so Express skips normal middleware and jumps to the error handler.

On Express 5 I don't need this, because the router does exactly this for me. But lots of production code is still on 4, and interviewers like it because it shows you understand why the error was getting lost.

IntermediateWrite JWT authentication middleware.

OK, so first I'd pin down the contract: read a Bearer token from the Authorization header, verify the signature and expiry, put the user on req.user, and respond 401 on anything wrong. Let me write this out.

import jwt from 'jsonwebtoken';

const { JWT_SECRET } = process.env;
if (!JWT_SECRET || JWT_SECRET.length < 32) throw new Error('JWT_SECRET missing or too short');

export function requireAuth(req, res, next) {
  const header = req.get('authorization') ?? '';
  const [scheme, token] = header.split(' ');

  if (scheme !== 'Bearer' || !token) {
    return res.status(401).json({ error: 'Missing bearer token' });
  }

  let payload;
  try {
    payload = jwt.verify(token, JWT_SECRET, {
      algorithms: ['HS256'],
      issuer: 'api.myapp.com',
      audience: 'myapp-web',
    });
  } catch (err) {
    const message = err.name === 'TokenExpiredError' ? 'Token expired' : 'Invalid token';
    return res
      .status(401)
      .set('WWW-Authenticate', 'Bearer error="invalid_token"')
      .json({ error: message });
  }

  req.user = { id: payload.sub, role: payload.role };
  next();   // outside the try, on purpose
}

// issuing, at login
const token = jwt.sign({ role: user.role }, JWT_SECRET, {
  subject: String(user.id), expiresIn: '15m', issuer: 'api.myapp.com', audience: 'myapp-web',
});

A few decisions worth calling out. I check the secret at module load, so a missing env var fails the boot, not the first request. I pin algorithms, and I check issuer and audience, so a token for a different service isn't accepted here. I use verify, never decode.

I only copy the fields I need onto req.user, rather than the whole payload. I distinguish "expired" from "invalid" in the message, because the client should refresh on the first and log out on the second.

And next() sits outside the try on purpose. next() is synchronous, so if it were inside, a bug that throws in a later handler would land in my catch and come back as a misleading 401 instead of a 500.

IntermediateWrite role-based authorisation middleware.

So this runs after authentication. It assumes req.user exists and checks whether their role is allowed. I'd make it a factory, so each route declares what it needs.

const ROLE_PERMISSIONS = {
  viewer: ['post:read'],
  editor: ['post:read', 'post:write'],
  admin:  ['post:read', 'post:write', 'post:delete', 'user:manage'],
};

export function authorize(...required) {
  return (req, res, next) => {
    if (!req.user) return res.status(401).json({ error: 'Not authenticated' });

    const granted = new Set(ROLE_PERMISSIONS[req.user.role] ?? []);
    const ok = required.every((p) => granted.has(p));
    if (!ok) return res.status(403).json({ error: 'Forbidden' });

    next();
  };
}

// usage
router.get('/posts', requireAuth, authorize('post:read'), listPosts);
router.delete('/posts/:id', requireAuth, authorize('post:delete'), deletePost);
router.patch('/users/:id/role', requireAuth, authorize('user:manage'), changeRole);

OK, so the reason I map roles to permissions, instead of writing authorize('admin', 'editor') on routes, is that roles change. When product adds a "moderator" role, I edit one table instead of hunting through every route file. The route says what action it needs, and the table says who has it.

Unknown roles get an empty set, so the default is deny. 401 when there's no user, 403 when there is one without permission.

The thing I'd mention straight away is that this only covers role checks. "Editors can edit posts" is not the same as "this editor can edit this post". Ownership still has to be checked in the handler or service, with the resource loaded, like post.authorId === req.user.id || role === 'admin'. Role middleware plus an ownership check is the combination that actually closes the holes.

IntermediateWrite request logging middleware with timing and a request ID.

OK, so I want one structured log line per request with method, path, status and duration, plus a request ID that's echoed back in the response header. The trick is logging when the response is done, not when the request arrives.

import { randomUUID } from 'node:crypto';

export function requestLogger(logger) {
  return (req, res, next) => {
    const start = process.hrtime.bigint();
    const incoming = req.get('x-request-id');
    req.id = incoming && /^[\w-]{1,64}$/.test(incoming) ? incoming : randomUUID();
    res.set('x-request-id', req.id);
    req.log = logger.child({ requestId: req.id });

    let logged = false;
    const done = (event) => {
      if (logged) return;
      logged = true;
      const ms = Number(process.hrtime.bigint() - start) / 1e6;
      const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info';
      req.log[level]({
        method: req.method,
        path: req.originalUrl.split('?')[0],
        status: res.statusCode,
        durationMs: Math.round(ms * 10) / 10,
        aborted: event === 'close' && !res.writableFinished,
      }, 'request completed');
    };

    res.on('finish', () => done('finish'));
    res.on('close', () => done('close'));
    next();
  };
}

Let me walk through the choices. I use process.hrtime.bigint() rather than Date.now() because it's monotonic, so clock adjustments can't produce negative durations. I reuse an incoming request ID from a gateway, but only if it looks sane, since a client could otherwise inject a huge or weird string into my logs.

finish fires when the response is fully handed off. close also fires if the client disconnects early, so I listen to both and guard with logged to write exactly one line. The aborted flag tells me when a user gave up waiting, which is great for spotting slow endpoints.

I log the path without the query string, because query strings sometimes contain tokens or emails. In production I'd usually use pino-http, which does this. But writing it shows you understand the response lifecycle.

IntermediateWrite validation middleware using zod.

So I want a middleware factory that takes schemas for body, query and params, validates each, returns a 400 with useful field-level errors, and passes the parsed data on, with coercions and defaults applied. Let me write this out.

import { z } from 'zod';

export function validate(schemas) {
  return (req, res, next) => {
    const valid = {};
    for (const part of ['params', 'query', 'body']) {
      if (!schemas[part]) continue;
      const result = schemas[part].safeParse(req[part]);
      if (!result.success) {
        return res.status(400).json({
          error: 'Validation failed',
          details: result.error.issues.map((i) => ({
            path: [part, ...i.path].join('.'),
            message: i.message,
          })),
        });
      }
      valid[part] = result.data;
    }
    req.valid = valid;
    next();
  };
}

// usage
const CreateUser = z.object({
  email: z.email(),
  age: z.coerce.number().int().min(18),
});
const ListQuery = z.object({ limit: z.coerce.number().int().max(100).default(20) });

app.post('/users', validate({ body: CreateUser }), (req, res) => {
  res.status(201).json(req.valid.body);          // { email, age: 30 } age is a number now
});
app.get('/users', validate({ query: ListQuery }), (req, res) => {
  res.json({ limit: req.valid.query.limit });    // 20 by default
});

For { "email": "x", "age": 12 }, this returns a 400 with two details: body.email "Invalid email address", and body.age "Too small: expected number to be >=18". I checked that against zod 4.

The design choice worth mentioning is req.valid instead of overwriting req.query. In Express 5, req.query is a getter, so assigning to it doesn't stick. Keeping parsed data separate also makes it obvious in the handler that I'm using validated values.

By default z.object strips unknown keys, which also helps with mass-assignment. Someone sending "role": "admin" on signup just gets it dropped. And the handler never sees invalid input, so I can drop a lot of defensive checks.

Interviewers look for: using the parsed output rather than the raw input, and consistent 400 error shapes.

IntermediateBuild a paginated list endpoint using a cursor.

OK, so first, why cursor rather than offset. OFFSET 10000 makes the database read and throw away 10,000 rows, and if rows are inserted while someone pages, they see duplicates or skip items. A cursor says "give me the rows after this one", which uses an index and stays stable.

I'll sort by created_at DESC, id DESC. The id is a tiebreaker, because two posts can share a timestamp.

const encodeCursor = (row) =>
  Buffer.from(JSON.stringify({ createdAt: row.createdAt, id: row.id })).toString('base64url');

function decodeCursor(cursor) {
  try {
    const c = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
    if (typeof c.createdAt !== 'string' || !Number.isInteger(c.id)) return null;
    return c;
  } catch { return null; }
}

app.get('/posts', async (req, res) => {
  const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);

  let after = null;
  if (req.query.cursor) {
    after = decodeCursor(req.query.cursor);
    if (!after) return res.status(400).json({ error: 'Invalid cursor' });
  }

  const { rows } = await pool.query(
    `SELECT id, title, created_at AS "createdAt" FROM posts
     WHERE ($1::timestamptz IS NULL OR (created_at, id) < ($1, $2))
     ORDER BY created_at DESC, id DESC
     LIMIT $3`,
    [after?.createdAt ?? null, after?.id ?? null, limit + 1],
  );

  const hasMore = rows.length > limit;
  const items = hasMore ? rows.slice(0, limit) : rows;
  res.json({ items, nextCursor: hasMore ? encodeCursor(items.at(-1)) : null });
});
// index: CREATE INDEX ON posts (created_at DESC, id DESC);

Walking through the tricks: I fetch limit + 1 rows. If I get the extra one, I know there's another page without a separate COUNT query. The cursor is built from the last returned item, not the extra one.

The row comparison (created_at, id) < ($1, $2) handles the tie correctly, and it can use the composite index. I encode the cursor as base64url so clients treat it as opaque, and I validate it after decoding, because it's user input like anything else.

The limit is clamped between 1 and 100, so nobody can ask for a million rows. When I tested this with 7 posts and a limit of 3, I got pages of 3, 3 and 1, then nextCursor: null, with no duplicates across timestamp ties.

AdvancedImplement an in-memory sliding-window rate limiter middleware.

So a sliding window means "at most max requests in any windowMs period ending now", not per fixed minute. A fixed window lets someone send max at 0:59 and another max at 1:00. The simplest exact version is a sliding log: keep the timestamps of recent requests per key, and drop the old ones.

export function rateLimit({ windowMs, max, key = (req) => req.ip }) {
  const hits = new Map();   // key -> sorted array of timestamps

  // sweep idle keys so memory doesn't grow forever
  const sweeper = setInterval(() => {
    const cutoff = Date.now() - windowMs;
    for (const [k, times] of hits) {
      while (times.length && times[0] <= cutoff) times.shift();
      if (times.length === 0) hits.delete(k);
    }
  }, windowMs);
  sweeper.unref();          // don't keep the process alive just for this

  return (req, res, next) => {
    const now = Date.now();
    const k = key(req);
    const times = hits.get(k) ?? [];
    while (times.length && times[0] <= now - windowMs) times.shift();

    if (times.length >= max) {
      const retryAfterMs = times[0] + windowMs - now;
      res.set('Retry-After', String(Math.ceil(retryAfterMs / 1000)));
      return res.status(429).json({ error: 'Too many requests' });
    }

    times.push(now);
    hits.set(k, times);
    res.set('RateLimit-Remaining', String(max - times.length));
    next();
  };
}

app.use('/api', rateLimit({ windowMs: 60_000, max: 100 }));

OK, so on each request I drop timestamps older than the window, check the count, and either reject with 429 or record the new timestamp. Retry-After is computed from the oldest timestamp in the window, which is exactly when a slot frees up. I tested it with max: 2 and a 1-second window: 200, 200, 429 with Retry-After: 1, and 200 again after a second.

Rejected requests aren't recorded, so a client that's being blocked doesn't extend its own ban. The sweeper removes keys that went quiet, otherwise every IP ever seen stays in the Map.

The trade-offs I'd mention: memory is O(max) per key, which is fine for 100 but not for 100,000. At that point I'd switch to a sliding-window counter, using two fixed buckets weighted by overlap, which is O(1) per key and approximate. And being in-memory, it's per process. With several instances I'd move this to Redis, with a sorted set per key, or a Lua script for atomicity.

Interviewers look for: correct window eviction, a 429 with Retry-After, cleanup of idle keys, and knowing it breaks across multiple instances.

AdvancedStream a large CSV export from the database without loading it into memory.

OK, so the naive version, fetch all rows, build one big string, res.send it, falls over at a few hundred thousand rows. I want to read rows in batches, turn each into a CSV line, and push them through to the response with backpressure, so memory stays flat whatever the size.

import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';

function csvCell(v) {
  const s = String(v ?? '');
  return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}

async function* fetchInBatches(userId, batchSize = 1000) {
  let lastId = 0;
  while (true) {
    const { rows } = await pool.query(
      'SELECT id, name, created_at FROM orders WHERE user_id = $1 AND id > $2 ORDER BY id LIMIT $3',
      [userId, lastId, batchSize],
    );
    if (rows.length === 0) return;
    yield* rows;
    lastId = rows.at(-1).id;
  }
}

async function* toCsv(rows) {
  yield 'id,name,createdAt\n';
  for await (const r of rows) {
    yield [r.id, r.name, r.created_at.toISOString()].map(csvCell).join(',') + '\n';
  }
}

app.get('/orders/export.csv', requireAuth, async (req, res) => {
  res.set({
    'Content-Type': 'text/csv; charset=utf-8',
    'Content-Disposition': 'attachment; filename="orders.csv"',
  });
  try {
    await pipeline(Readable.from(toCsv(fetchInBatches(req.user.id))), res);
  } catch (err) {
    if (err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;   // client cancelled
    req.log?.error({ err }, 'csv export failed');
    res.destroy(err);    // headers are already sent; cut the connection
  }
});

Walking through it: the async generator pages with keyset pagination, id > lastId, so each query is an index seek and no transaction stays open for the whole export. Readable.from turns the generator into a stream. And pipeline is the important piece. When the client downloads slowly, res signals backpressure, pipeline stops pulling from the generator, and the next DB query simply doesn't happen until there's room.

If the client disconnects, pipeline destroys the stream, and the generator stops, so I don't keep querying for nobody. Errors after the first byte can't become a nice 500, because headers are already sent. So I log and destroy the socket, and the client sees a failed download rather than a silently truncated file.

The csvCell escaping matters. I tested it with a name like O"Neil, Pat, which becomes "O""Neil, Pat". For exports opened in Excel, I'd also prefix cells starting with =, +, - or @ with a quote, to avoid CSV formula injection.

AdvancedImplement graceful shutdown for an Express server.

So when the orchestrator sends SIGTERM, I want to stop taking new work, let in-flight requests finish, close my resources in order, and exit. And if that takes too long, force it, because SIGKILL is coming anyway. Let me write this out.

const server = app.listen(PORT, () => logger.info({ port: PORT }, 'listening'));

let shuttingDown = false;
app.get('/readyz', (req, res) => res.status(shuttingDown ? 503 : 200).end());

async function shutdown(signal) {
  if (shuttingDown) return;             // SIGTERM + SIGINT, or a second Ctrl+C
  shuttingDown = true;
  logger.info({ signal }, 'shutting down');

  // hard deadline, below the platform's grace period (k8s default 30s)
  const force = setTimeout(() => {
    logger.error('forced shutdown after timeout');
    server.closeAllConnections();
    process.exit(1);
  }, 25_000);
  force.unref();

  try {
    // 1. stop accepting new connections; resolves when in-flight requests finish
    await new Promise((resolve, reject) =>
      server.close((err) => (err ? reject(err) : resolve())));
    // 2. then close what those requests were using
    await queueWorker?.close();
    await pool.end();
    logger.info('shutdown complete');
    process.exit(0);
  } catch (err) {
    logger.error({ err }, 'error during shutdown');
    process.exit(1);
  }
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

OK, so the order matters. First I flip shuttingDown, so readiness returns 503 and the load balancer stops sending traffic. Then server.close() stops accepting connections and waits for active requests. Since Node 19 it also closes idle keep-alive sockets, which used to be the classic reason shutdown hung. Only after that do I close the DB pool, because closing it first would fail the requests still running.

The forced timeout is unref()'d, so it doesn't itself keep the process alive, and it's shorter than the platform's grace period, so I get a log line instead of a silent SIGKILL. The shuttingDown guard makes it idempotent.

I tested the flow by sending SIGTERM to the process: it logged the signal, closed the server and exited with 0. The one thing outside this code that breaks it is starting the app with npm start in Docker, since the signal may never reach Node. So the container runs node server.js directly.

Interviewers look for: stop accepting, drain, then close resources in that order; a forced-exit timeout; and awareness of keep-alive connections and signal delivery in containers.