Full-Stack Questions
Node.js & Express Interview Questions
Event loop, streams, workers, Express 5, security, graceful shutdown and performance.
On this page
- Words used in this lesson
- The event loop, phase by phase
- Streams and backpressure
- workerthreads vs cluster vs childprocess
- Async patterns and AbortController
- ESM vs CommonJS
- Errors and process signals
- Express 5: what changed
- Middleware order and routers
- Security middleware
- Running in production
- Memory leaks and profiling
- Testing and observability
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: at 4+ years, interviewers stop asking "what is Node?" and ask why it behaves the way it does: the order the event loop runs things, what backpressure protects you from, how a process shuts down without dropping requests, and which Express 5 and middleware details cause real bugs.
You already know the basics from the earlier lessons: the event loop and blocking (Backend, Lesson 10), errors (Backend, Lesson 04), logging (Backend, Lesson 05), auth (Backend, Lesson 06), and CORS (Browser Contract, Lesson 04). This lesson does not repeat them. It goes one level deeper, and every output and number below comes from code that was run on Node 22 and Express 5.2.
Words used in this lesson
- Node.js is a program that runs JavaScript outside the browser, mostly for servers. Express is a small web framework for Node that routes requests through a chain of functions.
- Middleware is a function that gets
(req, res, next). It can read or change the request, answer it, or callnext()to pass it on. Think of a security desk, a ticket desk and a gate in a row. - A signal is a short message that the operating system sends to a process, such as
SIGTERM("please stop now") orSIGINT(Ctrl+C). - Backpressure is the way a slow receiver tells a fast sender to wait. Like a funnel: pour slower when it fills up.
- A microtask is a tiny job (a promise callback) that runs as soon as the current code finishes, before the loop moves on.
- ESM (ES modules) uses
importandexport. CommonJS (CJS) usesrequireandmodule.exports.
1. The event loop, phase by phase
The event loop is the part of Node that picks the next callback to run. It goes around in a circle of phases. For the interview you need these four, in this order:
| Phase | What runs there |
|---|---|
| timers | setTimeout and setInterval callbacks that are due |
| poll | finished I/O callbacks (files, sockets); waits here if nothing else is ready |
| check | setImmediate callbacks |
| close callbacks | things like a socket's 'close' event |
(Node also has "pending callbacks" and "idle/prepare" phases for its own use.) Two queues run in between the phases: first process.nextTick callbacks, then promise (microtask) callbacks. They drain completely before the loop moves on.
Here is a program, and the order it printed on Node 22:
// order.cjssetTimeout(() => console.log("timeout"), 0);setImmediate(() => console.log("immediate"));process.nextTick(() => console.log("nextTick"));Promise.resolve().then(() => console.log("promise"));console.log("sync");// sync, nextTick, promise, timeout, immediate (timeout and immediate can swap)Two details that interviewers love:
- From the main module,
setTimeout(0)vssetImmediateis not guaranteed. Starting up takes about a millisecond, so the timer may or may not be due. In my runs the order changed betweennode -eand a module file. Inside an I/O callback,setImmediatealways wins, because the check phase comes right after poll. - In an ES module, the promise runs before
nextTick. In CommonJS,nextTickcomes first. The reason: an ES module is itself evaluated inside a promise job, so the microtask queue is already being drained. The same two lines printednextTick, promisein a.cjsfile andpromise, nextTickin an.mjsfile. Many blog posts say "nextTick always first". That is only true for CommonJS.
Both queues can starve the loop. A nextTick that schedules another nextTick forever never lets timers or I/O run. In a test, 3 million chained ticks delayed a 1 ms timer by about 350 ms. Use setImmediate to give the loop a turn.
The libuv thread pool
libuv is the C library under Node that does I/O. It has a thread pool: a fixed group of worker threads for jobs the operating system cannot do without blocking. The default size is 4 (UV_THREADPOOL_SIZE, set in the environment before the process starts; setting it from inside the code is not guaranteed to work). It serves all fs calls, dns.lookup, crypto.pbkdf2/scrypt/randomBytes, and zlib. Network sockets do not use it.
I started eight pbkdf2 hashes at once on a 4-core machine. The first four finished together at about 430 ms. The other four finished near 900 ms. That is the queue. The practical result: eight slow password hashes can delay an unrelated fs.readFile, because they share the same four threads. More threads only help if you also have more CPU cores.
2. Streams and backpressure
A stream handles data in chunks instead of all at once. A writable stream has a buffer (a waiting area in memory) and a highWaterMark, the size at which it says "full". The rule is simple:
writable.write(chunk)returnsfalsewhen the buffer is over the mark. That means "stop writing".- When it empties, it emits
'drain'. Then you may write again.
The default mark is 64 KiB for byte streams in Node 22 (it was 16 KiB before Node 22), and 16 objects in object mode. If you ignore the false, the buffer grows until memory runs out. That is the classic "one slow client crashes the server" bug.
You rarely do this by hand. stream.pipeline does it for you, and it also destroys every stream when one fails:
import { Readable, Writable } from "node:stream";import { pipeline } from "node:stream/promises";const source = new Readable({ read() { this.push("x"); } });const broken = new Writable({ write(chunk, enc, cb) { cb(new Error("disk full")); } });try { await pipeline(source, broken); }catch (e) { console.log(e.message, source.destroyed); } // "disk full" truea.pipe(b) does not forward errors and does not clean up. Prefer pipeline. In an Express handler, await pipeline(fileStream, res) also stops reading the file if the client disconnects.
3. worker_threads vs cluster vs child_process
worker_threads | cluster | child_process | |
|---|---|---|---|
| What it starts | A thread with its own event loop, in the same process | Copies of your server process that share one port | Any other program |
| Memory | Own heap; can share a SharedArrayBuffer | Fully separate | Fully separate |
| Use it for | CPU-heavy work (hashing, parsing, images) | Using all CPU cores on one machine | ffmpeg, Python, shell tools |
| Talk with | postMessage | messages to the primary | stdin/stdout, IPC |
Difference in one line: worker_threads makes one process do more work at once, and cluster makes more processes answer requests. Under cluster, a crash only kills one worker, but a cache in memory is not shared. I ran a 3-worker cluster; the primary process used round-robin (it hands each new connection to the next worker) on Linux. In containers, most teams skip cluster and run more containers, as in Backend, Lesson 10. os.availableParallelism() tells you how many cores you can use.
4. Async patterns and AbortController
An AbortController is an object that lets you cancel work. You give its signal to the work, and later call controller.abort(). The work stops and rejects.
// 1. A timeoutawait fetch(url, { signal: AbortSignal.timeout(2000) }); // rejects with TimeoutError// 2. Cancel for your own reason, and keep the reasonconst ac = new AbortController();req.on("close", () => ac.abort(new Error("client left")));// 3. Stop on the first of many reasonsconst signal = AbortSignal.any([ac.signal, AbortSignal.timeout(2000)]);await setTimeout(5000, null, { signal }); // from "node:timers/promises"What I saw when I ran it: a timeout rejects with TimeoutError. abort(reason) makes fetch reject with your reason. Timers from node:timers/promises reject with an AbortError, and signal.reason tells you which reason fired. Pass the signal down through your own functions, and call signal.throwIfAborted() inside long loops.
The four promise helpers, in one line each:
Promise.all: all must succeed; fails fast on the first rejection (the other tasks keep running, they are not cancelled).Promise.allSettled: waits for all; gives{status, value|reason}for each.Promise.race: the first to settle, success or failure.Promise.any: the first to succeed; if all fail it rejects with anAggregateErrorthat holds every error.
A timeout that only rejects a promise does not stop the work behind it. Pass a signal to the work, or the database query keeps running after the user got a 504.
5. ESM vs CommonJS
| CommonJS | ES modules | |
|---|---|---|
| Syntax | require, module.exports | import, export |
| Loading | Synchronous | Asynchronous; supports top-level await |
__dirname | exists | does not; use import.meta.dirname and import.meta.filename |
| JSON | require("./a.json") | import data from "./a.json" with { type: "json" } |
| Chosen by | .cjs, or .js with no "type": "module" | .mjs, or .js with "type": "module" |
Facts that changed recently: since Node 22.12 (and 20.19), require() can load an ES module with no flag, unless it uses top-level await. I ran this: the sync module loaded fine, and the one with await threw ERR_REQUIRE_ASYNC_MODULE. So the old line "you cannot require an ES module" is out of date. The other direction always worked: import() can load CommonJS. Inside ESM you can still get require with createRequire(import.meta.url). A missing file in import fails with ERR_MODULE_NOT_FOUND, and in ESM you must write the file extension.
6. Errors and process signals
The error design is in Backend, Lesson 04. Process-level facts:
- Since Node 15 an unhandled promise rejection crashes the process. I ran
Promise.reject(new Error("lost"))and the process died. Withprocess.on("unhandledRejection")the handler is called instead. uncaughtExceptionfires for a throw that nothing caught. The program may be half-changed. Log, then exit; the process manager starts a clean one.SIGTERMandSIGINThave default handlers that exit at once. When you add your own handler, you own the shutdown.
7. Express 5: what changed
Express 5 needs Node 18 or newer. Install gives 5.2.1 today; Express 4 stays supported as the "latest-4" line. These are the changes worth knowing, each checked in code:
| Area | Express 4 | Express 5 |
|---|---|---|
| Async errors | You needed .catch(next) or a wrapper | A thrown error or rejected promise from a handler goes to the error handler for you |
| Wildcard | app.get("/*", ...) | app.get("/*splat", ...). req.params.splat is an array (["a","b","c"]) |
| Optional part | /:file.:ext? | /:file{.:ext}. A missing param is left out of req.params |
| Regex-like paths | /[abc]/x worked | Throws at start-up (Unexpected [) |
req.body with no parser | {} | undefined |
req.query | A writable property; "extended" parser | A getter you cannot assign to; "simple" parser |
express.urlencoded() | extended true | extended false: a[b]=1 stays the key "a[b]" |
req.params | Normal object | No prototype (null prototype) |
res.status(99) | Allowed | Throws: status must be 100 to 999 |
express.static dotfiles | Served | Default "ignore": /.well-known/... gives 404 |
app.listen(port, cb) | Errors were thrown | Errors (like EADDRINUSE) are passed to the callback |
Removed: app.del(), req.param(), res.sendfile() (use sendFile), and res.redirect("back"). For res.json(obj, 201): the docs say the signature is gone. In my run on 5.2.1 it did not throw. It answered 200 and ignored the 201. A silent wrong status is worse than an error, so use res.status(201).json(obj).
// Express 5: no try/catch and no wrapper neededapp.get("/users/:id", async (req, res) => { const user = await db.users.findById(req.params.id); // a rejection goes to the error handler if (!user) return res.status(404).json({ error: "not_found" }); res.json(user);});8. Middleware order and routers
Express runs middleware in the order you register it. Most "it does not work" bugs are order bugs. I tested each of these:
const app = express();app.use(requestId); // 1. first: every later log can use itapp.use(helmet()); // 2. security headers on every response, even errorsapp.use(cors(corsOptions)); // 3. before routes, so preflight (OPTIONS) is answeredapp.use(express.json({ limit: "10kb" })); // 4. body parser, before the routes that need itapp.use("/v1", v1Router); // 5. routesapp.use((req, res) => res.status(404).json({ error: "not_found" })); // 6. 404 after all routesapp.use((err, req, res, next) => { /* 7. error handler, always last, exactly 4 params */ });- A route registered before
express.json()seesreq.body === undefined. A route registered after the 404 handler is never reached (I got404). - An error handler is recognised by having four parameters.
(err, req, res)with three is treated as a normal middleware, so the error went to the default HTML page (500). Also, if headers were already sent, callnext(err)instead of writing again. next("route")skips to the next route for the same path.next(err)skips to the error handler.- Without
NODE_ENV=production, the default error page prints the stack trace. With it, you get plainInternal Server Error. It also turns on view caching. Set it in production, and still write your own handler.
A router is a mini app that groups routes. express.Router({ mergeParams: true }) lets a child router see the parent's params. router.param("orderId", fn) runs a function once for any route that has :orderId, which is a good place for ID checks. For versions, mount routers by prefix: app.use("/v1", v1), app.use("/v2", v2); the rules are in Backend, Lesson 02.
9. Security middleware
helmet sets a group of safe HTTP response headers in one line. On version 8, I saw it add: Content-Security-Policy, Strict-Transport-Security (max-age=31536000; includeSubDomains), X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy, and X-Frame-Options: SAMEORIGIN. It sets X-XSS-Protection: 0 on purpose (the old browser filter caused bugs), and it removes X-Powered-By. It is not a firewall, it does not do CORS, and the default CSP (script-src 'self') will block inline scripts on pages you serve. CSP itself is explained in Browser Contract, Lesson 07.
CORS (cross-origin resource sharing) is a browser rule that blocks a page from reading a response from another origin unless the server allows it (Browser Contract, Lesson 04). Facts from running the cors package:
cors()with no options sendsAccess-Control-Allow-Origin: *.- With an allow-list, a request from another origin still runs on the server; it just has no allow header. CORS protects the user's browser, not your API. Use real auth.
origin: "*"withcredentials: truesends both headers, and the browser rejects it.origin: truewith credentials reflects any origin, which is as bad as a wide-open API for logged-in users.- A preflight (
OPTIONS) got204withAccess-Control-Allow-Methods,-HeadersandMax-Age.
Rate limiting with express-rate-limit (v8) counts requests per key (the IP by default) in a time window and answers 429 when the limit is passed. Three things matter:
app.set("trust proxy", 1); // one proxy (the load balancer) in front of the appapp.use(express.json()); // the limiter reads req.body, so the parser must come firstapp.use("/login", rateLimit({ windowMs: 60_000, limit: 3, standardHeaders: "draft-8", legacyHeaders: false, keyGenerator: (req) => ipKeyGenerator(req.ip) + ":" + (req.body?.email ?? ""),}));trust proxy. Behind a load balancer every request seems to come from the balancer's IP, so everyone shares one bucket. Set the number of proxies in front. I ran with a forwarded header andtrust proxyoff, and the library printedERR_ERL_UNEXPECTED_X_FORWARDED_FOR. Too high a number is also wrong: users can fake the header and dodge the limit.- The store. The default store is in memory, per process. With 4 instances the real limit is 4 times bigger. Use a shared store such as Redis.
- The key. For login, limit by IP and account name, so one attacker cannot lock out a user from everywhere. Wrap IPs with
ipKeyGeneratorso IPv6 users cannot dodge by changing the last part of their address.
Cookies. res.cookie("sid", value, { httpOnly: true, secure: true, sameSite: "lax", signed: true, maxAge }). With cookie-parser and a secret, a changed signed cookie reads as false. Express 5's res.clearCookie ignores maxAge and expires, so call res.clearCookie("sid", { path: "/" }) with the same path and domain you used to set it. JWT: always pin algorithms: ["HS256"] in verify and check iss and aud. I confirmed that a token with alg: none is rejected when you pin the algorithm. Session versus token trade-offs are in Browser Contract, Lesson 02.
File uploads with multer (v2): set limits.fileSize and limits.files. A file over the limit raised LIMIT_FILE_SIZE, which you map to 413. A fileFilter that returns false silently skips the file; the route still runs with req.file empty. The mimetype is just what the client claimed: a 16-byte text file named evil.png with type image/png passed. Check the real file signature (magic bytes), never trust the name, and better still let clients upload straight to object storage with a presigned URL.
10. Running in production
Graceful shutdown
Graceful shutdown means finishing the requests in progress before the process exits. The order matters. This is the code I ran; a 1.5 s request that was in flight when SIGTERM came still returned 200, and a new request got 503:
let ready = true;app.get("/readyz", (req, res) => res.sendStatus(ready ? 200 : 503));async function shutdown(signal) { ready = false; // 1. readiness fails: balancer stops sending traffic await new Promise((r) => setTimeout(r, 5000)); // 2. wait for the balancer to notice (use your real value) setTimeout(() => { server.closeAllConnections(); process.exit(1); }, 25_000).unref(); // 3. hard deadline server.close(async () => { await db.close(); process.exit(0); }); // 4. wait for in-flight work, then close the DB server.closeIdleConnections(); // 5. drop idle keep-alive sockets}process.on("SIGTERM", () => shutdown("SIGTERM"));process.on("SIGINT", () => shutdown("SIGINT"));server.close() stops new connections and waits. Since Node 19 it also closes idle keep-alive sockets for you; I confirmed the callback fired with an idle socket open. Active requests still hold it open, hence the deadline. Make the deadline shorter than the platform's kill timeout (Kubernetes waits 30 s by default before SIGKILL). Liveness says "restart me if dead". Readiness says "send me traffic or not". Do not use one endpoint for both.
Containers, PM2 and the first process
A process manager such as PM2 restarts crashed processes and can run a cluster. In containers, the orchestrator does that, so run node directly. Two traps:
CMD ["npm", "start"]: npm does not pass signals to your app. I sentSIGTERMtonpm start(npm 10) and the app never saw it and kept running. UseCMD ["node", "server.js"].- Node as PID 1 (the first process in a container) gets no default signal handlers from Linux. You need your own
SIGTERMhandler as above, or an init program such astini(docker--init) that forwards signals.
Timeouts behind a load balancer
On Node 22 the defaults are keepAliveTimeout 5 s, headersTimeout 60 s and requestTimeout 300 s. Many load balancers (AWS ALB, for example) keep an idle connection for 60 s. If Node closes an idle connection after 5 s at the same moment the balancer reuses it, the client sees a random 502. The fix: set server.keepAliveTimeout higher than the balancer's idle timeout (for example 65 s), and headersTimeout a little above that. This default may change in newer Node versions, so check yours.
Config
Read environment variables once, at start, and check them with a schema so the app fails fast:
// node --env-file=.env server.js (built in; or process.loadEnvFile())import { z } from "zod";const Env = z.object({ PORT: z.coerce.number().default(3000), NODE_ENV: z.enum(["development", "test", "production"]).default("development"), DATABASE_URL: z.string().url(),});export const env = Env.parse(process.env); // throws with the field name if something is missingEnv values are always strings; z.coerce.number() turns "3000" into a number. Never commit .env. More in Backend, Lesson 03.
11. Memory leaks and profiling
A memory leak is memory that is no longer needed but still reachable, so the garbage collector (the part that frees unused memory) cannot free it. Usual causes: a Map used as a cache with no limit, listeners added on every request, and timers that are never cleared. Node warns about one of them: more than 10 listeners for one event gives MaxListenersExceededWarning.
How to look, in order:
- Watch
process.memoryUsage()over time:heapUsed(JavaScript objects),externalandrss(all memory the process holds).Buffers live outside the heap. In my test, aMapholding 10 KB buffers showedheapUsedgoing from 5 MB to 7 MB butexternalgoing from 49 MB to 145 MB. If you only watch the heap you miss it. - Take two heap snapshots some minutes apart and compare them in Chrome DevTools. Start with
--heapsnapshot-signal=SIGUSR2, or callv8.writeHeapSnapshot(). Look for the object type that grew and read its "retainers" (what holds it). - For CPU, use
--cpu-profor--inspectand read the flame graph. - Fix: use an LRU cache with a size limit, remove listeners, clear timers, and put limits on queues.
12. Testing and observability
supertest calls your app without listen(); it picks a free port itself. Export a createApp() and keep server.js for the listen call. Here is a run on node --test (all five passed):
import { test } from "node:test";import request from "supertest";import { createApp } from "./app.mjs";const app = createApp();test("malformed JSON gives 400, not 500", async () => { await request(app).post("/v1/echo").set("content-type", "application/json").send("{bad").expect(400);});test("body over the limit gives 413", async () => { await request(app).post("/v1/echo").send({ x: "a".repeat(20_000) }).expect(413); // limit is 10kb});For tracing, AsyncLocalStorage keeps a value (like a request ID) available to every function called during one request, without passing it around:
const als = new AsyncLocalStorage();app.use((req, res, next) => als.run({ id: req.get("x-request-id") ?? randomUUID() }, next));// anywhere later: als.getStore().idI tested that the ID sent in the header came back inside a handler and in the response. Logs, metrics and traces are in Backend, Lesson 05.
Common mistakes
- Saying
nextTickalways runs before promises (wrong in ES modules). - Ignoring the
falsefromwrite(), or using.pipe()without error handling. - Starting the container with
npm start, and having noSIGTERMhandler. - Putting the error or 404 handler in the wrong place, or giving the error handler three parameters.
- No
trust proxybehind a load balancer, or an in-memory rate-limit store on many instances. cors({ origin: true, credentials: true }), which allows every site.- Trusting an upload's file name or
mimetype, and watching onlyheapUsed.
Interview questions
(Must know) What are the main phases of the event loop, and in what order do nextTick, promises, setTimeout(0) and setImmediate run?
The loop goes through timers, poll (I/O callbacks), check (setImmediate) and close callbacks. Between steps, Node empties the nextTick queue and then the promise queue. In CommonJS, from the main module the order is sync code, nextTick, promises, then timer and immediate. The timer-versus-immediate order is not guaranteed from the main module. Inside an I/O callback, setImmediate always runs first. I also say that both queues can starve the loop if they keep adding to themselves.
(Trap) In an ES module, does process.nextTick still run before a resolved promise?
No. In an ES module the promise callback ran first in my test, because the module is evaluated inside a promise job and the microtask queue is already being drained. In CommonJS, nextTick runs first. So the claim "nextTick always wins" is only true for CommonJS. In real code I never depend on this order. I use await and setImmediate for clarity.
(Must know) What is the libuv thread pool? Scenario: logins become slow and unrelated file reads also slow down when many users log in at once. Why?
The pool is four threads (by default) that run fs, dns.lookup, zlib and some crypto calls such as pbkdf2 and scrypt. Password hashing is slow on purpose, so many logins fill all four threads and other pool work waits in the queue. In my test, eight hashes finished in two groups of four. I can raise UV_THREADPOOL_SIZE (before the process starts), but that only helps up to the number of CPU cores. Better: limit login concurrency, rate-limit logins, and scale out with more instances.
(Must know) What is backpressure and how do you handle it?
It is the signal a slow consumer sends so the producer slows down. write() returns false when the buffer passes highWaterMark (64 KiB by default in Node 22), and 'drain' says it is safe to continue. Ignoring it makes memory grow with one slow client. I use stream.pipeline, which handles backpressure and also destroys all streams on error. For example, await pipeline(fileStream, gzip, res).
(Senior signal) workerthreads, cluster or childprocess? How do you choose?
I ask what the bottleneck is. Heavy CPU work in a request (image resize, big parsing): worker_threads in a pool, so the main loop stays free. All cores for an I/O-bound API: cluster, or more likely more containers. An outside program such as ffmpeg: child_process. If the job can wait, a queue and a separate worker service beats all three. With cluster, memory is not shared, so in-memory caches and rate-limit counters must move to Redis.
(Senior signal) How do you add timeouts and cancellation to async work?
I use AbortController. AbortSignal.timeout(ms) gives a signal that aborts with a TimeoutError. AbortSignal.any([a, b]) merges signals, for example "client disconnected or 2 s passed". I pass the signal into fetch, timers and my own functions, and call signal.throwIfAborted() in loops. The point is that a timeout which only rejects the caller's promise does not stop the real work. Only the signal does.
(Must know) ESM vs CommonJS: what are the differences, and what changed recently?
CommonJS is synchronous with require; ESM is asynchronous with import and supports top-level await. In ESM there is no __dirname; I use import.meta.dirname. JSON import needs with { type: "json" }. Newer change: from Node 22.12 and 20.19, require() can load an ES module unless it uses top-level await, which gives ERR_REQUIRE_ASYNC_MODULE. So mixed code bases are much easier now. For a new service I choose ESM with TypeScript.
(Trap) An uncaughtException handler logs the error and keeps the server running. Is that fine?
No. After an uncaught exception I cannot know which state is half changed: open transactions, locks, broken invariants. The safe plan is log, stop accepting work, finish what I can quickly, and exit with a non-zero code, so the process manager or orchestrator restarts a clean process. unhandledRejection is already fatal by default in modern Node, so I treat it the same way and fix the missing await or catch.
(Senior signal) Design a graceful shutdown for an Express service on Kubernetes.
On SIGTERM: set readiness to fail so the balancer stops sending traffic, and wait a few seconds for that to take effect. Call server.close() to stop new connections and wait for in-flight requests, and call closeIdleConnections(). Then close the database, queue and cache clients, and exit with 0. A hard timer, shorter than the Kubernetes grace period (30 s by default), calls closeAllConnections() and exits non-zero. I tested this: a request in flight during SIGTERM still got its 200, and a new one got 503. I also separate liveness from readiness checks.
(Trap) The pod does not shut down gracefully, and it takes exactly the full grace period before it is killed. What do you check?
First, how the container starts. With npm start or a shell script, the signal goes to npm or the shell, not to Node; in my test npm did not pass SIGTERM on. I switch to CMD ["node", "server.js"]. Second, whether Node is PID 1 without a handler; I add one or use tini. Third, whether something keeps the server open: keep-alive sockets, an open interval, or an unclosed database pool.
(Must know) What changed in Express 5?
The biggest: rejected promises and thrown errors in async handlers go to the error handler automatically. Paths use a new syntax: /*splat for wildcards (an array in req.params), {...} for optional parts, and no regex-like characters. req.body is undefined without a parser; req.query is a getter using the simple parser; express.urlencoded is not extended by default. req.params has a null prototype. Static dotfiles are ignored by default. Removed APIs include app.del, req.param(), res.sendfile. It needs Node 18+. I mention that res.json(obj, 201) did not throw for me but returned 200, so I search the code base for the old signatures.
(Trap) Scenario: after you add a global error handler, errors still show an HTML stack trace. And a new route returns 404. What are the likely causes?
For the HTML error: the handler has three parameters instead of four, so Express does not treat it as an error handler; or it is registered before the routes. For the 404: the route is registered after the catch-all 404 handler. The rule is: parsers and security middleware first, then routes, then the 404, then the error handler last. I also check res.headersSent and call next(err) in that case, and I set NODE_ENV=production so the default page does not print stacks.
(Must know) What does helmet do, and what does it not do?
It sets a bundle of response headers: CSP, HSTS, nosniff, Referrer-Policy, frame options, cross-origin policies, and it removes X-Powered-By. These reduce the damage from XSS, clickjacking and content sniffing in browsers. It does not fix bugs in my code, does not handle CORS, and does not replace auth, validation or a WAF. For a pure JSON API many of the headers do not matter much, but they are free. With pages, the default CSP can block inline scripts, so I tune it.
(Trap) Does CORS protect my API? What goes wrong with credentials: true?
No. CORS is enforced by the browser only. A curl script or another server ignores it, and the request still runs on my server, so I need real authentication. With credentials, the allowed origin must be one exact origin, not *. The browser rejects * with cookies. And origin: true with credentials echoes back any origin, so any site can make logged-in calls. I use an explicit allow-list, answer preflight before auth, and set Max-Age to cut repeated preflights.
(Senior signal) How do you add rate limiting to a login route behind a load balancer, on four instances?
I set trust proxy to the exact number of proxies so req.ip is the real client. I use express-rate-limit with a shared Redis store, because the memory store is per process and the real limit would be four times higher. The key is IP plus account name, with ipKeyGenerator for IPv6. I return 429 with Retry-After. The limit is strict on /login and looser elsewhere. Rate limiting does not replace account lockout, strong password hashing and monitoring.
(Senior signal) How do you design file uploads in an Express API?
My first choice is a presigned URL, so the file goes straight to object storage and never touches my server. If it must go through Express, I use multer with limits.fileSize and limits.files, map LIMIT_FILE_SIZE to 413, and store on disk or stream to storage, not memory, for large files. I do not trust the file name or mimetype: I check the file signature, give files a random name, store them outside the web root, and scan if the risk needs it. I also remember that a rejected file in fileFilter is skipped silently, so I check req.file in the handler.
(Senior signal) Scenario: memory grows slowly and the process is killed by the platform after some hours. How do you find the leak?
I add memory metrics first: heapUsed, external and rss. If only external grows, the cause is probably Buffers (uploads, caches), which a heap snapshot may not show. If heapUsed grows, I take two heap snapshots a few minutes apart (--heapsnapshot-signal or v8.writeHeapSnapshot()) and compare them in DevTools to see which type grows and what retains it. Typical causes are an unbounded Map, listeners added per request, and uncleared timers. The fix is an LRU cache with a limit and cleanup code. A restart is only a bandage.
(Trap) Users see random 502 errors from the load balancer, but the app logs show no errors. Why?
A likely cause is the keep-alive timeout. On Node 22, keepAliveTimeout is 5 s, but the load balancer may keep idle connections for 60 s. When Node closes an idle connection at the same moment the balancer sends a request on it, the balancer gets a reset and returns 502. The app never saw the request, so there is no log line. I set keepAliveTimeout above the balancer's idle timeout (for example 65 s) and headersTimeout above that. I check the balancer's access logs to confirm.
Key takeaways
- Order: sync code,
nextTick, promises, timers, I/O,setImmediate. In ES modules, promises come beforenextTick. - The libuv pool (4 threads by default) is shared by
fs,dns.lookup,zliband somecrypto. - Respect
write()returningfalse, or usepipeline. UseAbortSignalfor real cancellation. - Express 5 handles async errors, but path syntax and several defaults changed. The error handler goes last and has four parameters.
- Pin JWT algorithms, set
trust proxy, share rate-limit state, and never treat CORS as protection. - Shut down in order: fail readiness, wait,
server.close(), close dependencies, hard deadline. Start containers withnode, notnpm. - Watch
externalandrss, not only the heap.
What's next
Lesson 07: MongoDB Interview Questions for 4+ Years. Most Node services keep their data somewhere. Next is the document database that pairs with Express: schema design, indexes, aggregation, transactions and sharding.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Express and Fastify: Middleware, Routing and Validation · Node.js From Zero
- API Folder Structure: Routes, Controllers, Services, Repositories · Backend in Depth
- Error Handling: Operational vs Programmer Errors · Backend in Depth
Part of the study guide
Related series