Appearance
9.9.1 — Express: What It Adds, and How Middleware Actually Works
Most people learn Express backwards. They copy an app.use(express.json()) line from a tutorial, it works, and Express stays a box of magic incantations that mostly do the right thing. Then an interviewer asks why error middleware needs four parameters, or why the request hung with no error in the logs, and the magic turns out to have been three fairly simple mechanisms all along.
So this page starts one level below Express, with the HTTP server Node already gives you, and adds Express back piece by piece.
1. Raw http first: what you'd write without Express
Node ships a complete HTTP server. Here is a real one:
javascript
import http from "node:http";
const server = http.createServer(async (req, res) => { // ONE handler for every request
if (req.url === "/users" && req.method === "GET") { // (1) manual routing
res.writeHead(200, { "Content-Type": "application/json" }); // (2) manual headers
res.end(JSON.stringify(await listUsers()));
return;
}
if (req.url?.startsWith("/users/") && req.method === "GET") { // (3) manual param parsing
const id = req.url.split("/")[2];
/* … */
}
if (req.method === "POST") { // (4) manual body reading
let body = ""; // (it's a STREAM — 3.8.4)
req.on("data", (chunk) => (body += chunk));
req.on("end", () => { const parsed = JSON.parse(body); /* … */ });
return;
}
res.writeHead(404).end("Not found"); // (5) manual fallthrough
});
server.listen(3000);Everything works — and every line of it is boilerplate you'd rewrite per project. Express (9.4.24 named it: a CoR runtime with a Factory entry) adds exactly three things, and knowing that they're only three is the honest answer to "what does Express do?": ⚑Difference between Express and Node HTTP module? [EQ-140]
- Routing — declarative method+path matching with parameters (
app.get("/users/:id", …)) instead of theifladder. - The middleware pipeline — a composable chain of functions each request flows through (section 2 — the real product).
- Convenience over
req/res—res.json(),res.status(),req.params/query/body, content negotiation: sugar over the raw streams.
What Express deliberately leaves out matters just as much, and it is the thing people are surprised by when they arrive from a bigger framework. Express does not tell you how to lay out your project. It does not give you a database layer, a way to wire dependencies together, a validation system, or a scheme for organising errors. Those are all still your decisions, and [9.9.6] is where they land on you.
One fact demystifies a large part of the ecosystem, so it is worth stating on its own: app is itself a request-handler function. When you call app.listen(3000), all that happens is:
javascript
http.createServer(app).listen(3000); // this is literally what app.listen doesSince app is just a function of (req, res), you can pass it to http.createServer yourself when you need the server object — to attach a WebSocket server to the same port, for instance. You can mount one Express app inside another. And you can hand it to a serverless adapter, which is exactly how the same code runs both on your laptop and on a platform that has no long-running server at all. ⚑What is app.listen()? [EQ-142]
2. Middleware: the mechanics
A middleware is a function (req, res, next) that runs on the way to (and possibly instead of) the route handler:
javascript
app.use((req, res, next) => { // signature: request, response, "the rest of the chain"
req.startedAt = Date.now(); // (a) enrich the request
console.log(`${req.method} ${req.url}`);
next(); // (b) HAND OFF — without this, the request HANGS
});Everything about Express comes down to what a middleware does with next. There are four possible behaviours, and each one is a tool you use deliberately: ⚑What is next()? [EQ-159]
Call next(). Control moves to the next middleware that matches this path, in the order they were registered. This is the normal case — you did your bit and the request continues.
Respond without calling next(). The chain stops right there and nothing after it runs. This is not a failure mode, it is the entire mechanism behind every rejection in your app: authentication returning 401, a rate limiter returning 429, a cache middleware answering from memory so the handler and the database are never touched.
Call next(err) with an argument. Express treats any argument as an error, skips every remaining ordinary middleware, and jumps to the error pipeline ([9.9.3] covers it in full). Note the shape of this: an error does not travel back up to the caller as an exception would. It jumps forward to a different set of handlers at the end of the chain.
Do neither — no response, no next(). The request hangs. Not for a moment, but until the client eventually gives up. And here is why this is the single most common Express bug: it produces nothing. No error, no log line, no stack trace. The socket simply stays open holding a connection, and if it happens on a busy route you run out of connections and the whole process appears to freeze for reasons nothing in your logs explains.
The usual cause is an async middleware whose promise rejected. The await threw, the function stopped, and nobody ever called next or responded — which is why [9.9.3] fixes this with a wrapper rather than with a reminder to be careful.
Underneath, this is the Chain of Responsibility from 9.4.16, with one twist. In the classic version each link returns a value to the next. Here nothing is returned; the links share and mutate the same req and res objects. Express keeps an array of layers, each holding a path and a handler, and hands every layer a next function that is really just "advance the index and call the layer there". You have already written this yourself in about twelve lines; Express's Router internals are that plus path matching.
Execution order is registration order — top to bottom, with matching paths only. This is not a detail; it's the security model: ⚑How does middleware execution order work? [EQ-162]
javascript
app.use(helmet()); // ① security headers on EVERYTHING (even 404s)
app.use(express.json()); // ② body parsed before handlers need req.body
app.use(rateLimit(...)); // ③ limit before auth (cheap check first)
app.use(authenticate); // ④ identity before authorization
app.use("/admin", requireAdmin); // ⑤ path-scoped: only /admin/* pays this cost
app.use("/api", apiRouter); // ⑥ the routes
app.use(notFoundHandler); // ⑦ nothing matched → 404
app.use(errorHandler); // ⑧ LAST, always — 4 args (9.9.3)Now move two lines and watch what happens.
Move ④ below ⑥, and every route in your application is public. The routes matched first, responded, and the request never reached authenticate. No error is raised anywhere, because nothing went wrong from Express's point of view — you simply registered your authentication after your routes.
Move ② below ⑥, and req.body is undefined inside every handler. Again, no error. The handler reads req.body.email, gets a crash on undefined, or worse gets undefined into a database write.
Both of these pass code review easily, because the diff looks harmless and the lines are all still present. That is why "explain middleware order" is asked in nearly every Node interview: it is the shortest question that distinguishes someone who has read the docs from someone who understands that this file is a sequence, not a list.
next() (red, upper), or divert to the error pipeline with next(err) (red, lower). Response-side work happens as the stack unwinds (purple).3. The four types of middleware
The taxonomy interviewers ask for, each with what it's for: ⚑Types of middleware? [EQ-161]
Application-level — app.use(fn) or app.use("/path", fn): runs for every request (or every request under a path prefix). Home of cross-cutting concerns: security headers, body parsing, logging, auth.
Router-level — router.use(fn): identical mechanics, scoped to a Router instance ([9.9.2] builds routers). This is how a feature owns its own pipeline — adminRouter.use(requireAdmin) means every admin route is protected by construction, and nobody can add an unprotected one. ⚑What is router-level middleware? [EQ-164]
Error-handling — (err, req, res, next), four parameters: Express inspects fn.length (JavaScript's function arity — 3.6.1) to distinguish these from normal middleware. That's the entire mechanism behind the famous question, and [9.9.3] does the full treatment. ⚑Why must error middleware have 4 arguments? [EQ-166b]
Built-in and third-party — Express ships three built-ins (all formerly separate packages, re-absorbed in 4.16+):
express.json()— reads bodies sent asContent-Type: application/jsonand puts the parsed object onreq.body. Two consequences people meet the hard way. First, it consumes the request stream (3.8.4): the body arrives in chunks, and this middleware reads all of them to build the string it parses. Once read, they are gone. So a route that wants to stream an upload straight to storage must be registered before this middleware, or excluded from it, otherwise the bytes it needs have already been eaten. Second, thelimitoption — 100kb by default — is a genuine defence and not a formality. Without it, one request claiming to be JSON can stream gigabytes into your process's memory, and the process dies. ⚑What is express.json()? [EQ-144]express.urlencoded({ extended: true })— parses HTML form posts (application/x-www-form-urlencoded).extended: trueuses theqslibrary (nested objects:user[address][city]);falseuses Node'squerystring(flat only). ⚑What is express.urlencoded()? [EQ-145]express.static("public")— serves files from a directory, handlingContent-Type,ETag/Last-Modifiedconditional requests (9.6.3), and ranges. Honest note: in production, static assets usually belong to Nginx or a CDN ([9.9.7]) —express.staticoccupies your event loop with file I/O (3.8.2's thread pool) for work a reverse proxy does better. ⚑What is express.static()? [EQ-146]
Third-party middleware is the ecosystem's whole shape: helmet, cors, morgan, multer, express-rate-limit, compression, express-session — every one is a function returning (req, res, next), which is why they compose without knowing about each other ([9.9.5] wires the security set).
4. The expert lens
Express is small, and that is both the feature and the bill. Routing plus a pipeline, and almost nothing else, means near-zero overhead, complete control, and an ecosystem of plugins that has outlived several generations of frameworks that were going to replace it.
The bill arrives in the form of decisions nobody makes for you. Where do files go? How are dependencies wired? Where does validation happen? What is an error in this codebase, and what shape does it have? A larger framework answers all of those before you write a line. Express answers none.
Teams that do well with Express make those decisions on purpose, write them down, and enforce them ([9.9.6] is that page). Teams that do not end up with one codebase containing four dialects, where each feature folder reflects whoever built it and what they had read that month. Choosing Express means choosing to own your architecture. That is a perfectly good choice as long as somebody notices they made it.
The pipeline is the architecture. Almost every production concern in an Express app turns out to be a question about ordering: security headers before anything can respond, body size limits before parsing, identity before permissions, error handling last, response-side work like compression and timing wrapped around the rest.
This has a practical consequence you can use immediately. Open an unfamiliar Express codebase, find the app.use sequence, and read it top to bottom. In about thirty seconds you know its security posture, whether it has request logging, how it handles failures, and what it forgot. That is the code-reading skill from 9.4.24 applied to the framework you will meet most often.
Keep the layer underneath in view. app is a function. res is a http.ServerResponse, which is a Writable stream. req is a Readable stream (3.8.4).
Holding those three facts changes what you can do. You can pipe a four-gigabyte file to a client without ever holding it in memory, because res is a stream and streams are what you pipe into. You can attach a WebSocket server to the same HTTP server your app runs on. And you finally understand the error everyone hits: Cannot set headers after they are sent means the response stream already wrote its header section, so res.json() after res.send() is asking to rewrite bytes that are already on the wire — usually because a handler forgot to return after responding.
Next: 9.9.2 — routing in full: composing routers, params versus query, Router.param(), chaining, app.locals and res.locals, and what trust proxy really changes.
What the interviewer will push on
Express questions are a reliable way to find out whether a candidate has debugged a Node service at 2am or only built one.
"What does Express give you over the built-in http module?" They are checking whether you can be specific. Name the three things — routing, the middleware pipeline, and convenience on req/res — and then say what it deliberately withholds, because that half shows you know it is a library and not a framework. The strongest single line is that app is itself a request handler, which is why app.listen is one line of sugar.
"A request is hanging with no error in the logs. Where do you look?" This is the diagnostic question, and the answer is a middleware that neither responded nor called next(). Then go one level further: the usual reason is an async function that rejected, since a thrown error inside an async middleware does not reach Express by itself. The strong candidate says how they would prevent the whole class of bug — a wrapper that catches rejections and forwards them to next — rather than describing how they would find this one instance.
"Why does error middleware take four arguments?" They want to know whether you understand it as a mechanism or as a rule. Express checks fn.length, the number of declared parameters, to decide which layers are error handlers. That is why a four-parameter function is required even if you never use next inside it, and why removing the unused fourth parameter silently converts your error handler back into an ordinary middleware that will never run.
"In what order do these middleware run, and what breaks if I move one?" Registration order, matching paths only. The two examples worth having ready are authentication registered after routes, which makes everything public, and the body parser registered after routes, which makes req.body undefined everywhere. Both matter because both are silent.
"How would you apply a rule to every route in one area of the app?" The answer is router-level middleware, and the reason is worth saying out loud: adminRouter.use(requireAdmin) protects every route on that router by construction, including the one somebody adds next year. It turns "remember to add the guard" from a discipline problem into a structural one, which is the only version that survives team turnover.
Volunteer this one, because nobody asks: point out that express.json() consumes the request stream, so any route that needs the raw body — a file upload streamed to storage, or a webhook whose signature is computed over the exact bytes received — must be registered before it or excluded from it. Webhook signature verification failing for no visible reason is a genuinely nasty afternoon, and knowing why in advance is a strong signal.
Recall
- Express = raw
http+ three additions: routing, the middleware pipeline,req/resconvenience.appis itself a request handler (app.listen=http.createServer(app).listen) — mountable, adaptable, testable. It deliberately supplies no architecture. - next() contract: call it → continue; respond without it → short-circuit; do neither → the request hangs silently (the #1 Express bug);
next(err)→ jump to the error pipeline. Mechanically it's 9.4.16'scomposeover a layer array, with mutablereq/res. - Registration order = execution order and it is the security model (auth below routes = public routes; parsers below handlers =
undefinedbodies). Standard order: headers → parsers → limits → authn → authz → routes → 404 → error handler last. - Four types: application-level (
app.use), router-level (feature-owned pipelines — protection by construction), error-handling (4 params, detected viafn.length), built-in/third-party —express.json()(consumes the stream;limitis a DoS control),express.urlencoded({extended})(qsvsquerystring),express.static(prefer Nginx/CDN in production). - Lens: smallness = control + the obligation to decide architecture yourself; the
app.usesequence is the architecture; know thehttplayer beneath (streams, headers-already-sent).
Self-test: Name Express's three genuine additions and what it refuses to provide. Give all four outcomes of the next() contract. Why does moving express.json() below the routes break handlers silently? How does Express distinguish error middleware? Why is express.static a production smell at scale?
Quiz Bank
FoundationalWhat is Express, and what does it actually add over Node's http module?
Express is a minimal, unopinionated HTTP framework — a thin layer over http adding exactly three things: (1) routing (declarative method+path matching with parameters, replacing the manual if (req.url === …) ladder), (2) the middleware pipeline (composable (req, res, next) functions every request flows through — the real product), and (3) req/res convenience (res.json(), res.status(), req.params/query/body, content negotiation) over the raw Readable/Writable streams. What it does not add is equally definitional: no ORM, no DI container, no project layout, no error taxonomy, no validation — those are your decisions ([9.9.6]). Two facts that demystify it: app is itself a (req, res) handler function (so app.listen(3000) is sugar for http.createServer(app).listen(3000), and app can be mounted, adapted to serverless, or handed to a test harness), and the pipeline is 9.4.16's Chain of Responsibility — twelve lines you can write yourself.
FoundationalExplain next() completely — all four outcomes and the failure mode.
next is the closure Express passes each middleware that advances to the next matching layer. (1) next() — control passes to the next middleware/handler in registration order. (2) Respond without calling next() (res.json(...), res.status(401).end()) — short-circuit: the chain stops there; nothing downstream executes. This is the mechanism behind auth rejection, cache hits, and rate-limit denials. (3) next(err) — with any argument, Express skips all remaining normal middleware and jumps to the first error-handling middleware (4 params); passing next("route") is the special case that skips to the next route instead. (4) Neither respond nor call next() — the request hangs until the client times out: no error, no log, no stack trace, just a socket held open (and, at volume, exhausted connections). This is the most common Express bug, and its usual cause is an async middleware whose promise rejected without a catch — which is why [9.9.3]'s async-error wrapper is structural, not stylistic.
AppliedGive the standard middleware order for a production API and say what breaks if each layer moves.
helmet() → cors() → express.json({ limit }) → request logging/traceId → rateLimit → authenticate → route-scoped authorize → routers → 404 handler → error handler (last). What breaks on movement: helmet below routes → responses (including 404s and errors) ship without security headers; body parser below routes → req.body is undefined in every handler (silent — the handler sees a missing field, not an error); rate limit below auth → unauthenticated floods pay full auth cost (JWT verification, DB lookups) before rejection, so the limiter protects nothing expensive; auth below routes → every route is public (the catastrophic one, and it passes review unless the reader knows order matters); error handler not last → errors from later middleware skip it and fall to Express's default handler, which leaks stack traces in development mode and returns bare HTML in production; 404 handler above routes → every request 404s. The general rule: cheap rejections first, identity before authorization, parsers before consumers, and the two terminators last in fixed order.
InterviewWhat are the four types of middleware, and what is router-level middleware genuinely for?
Application-level (app.use(fn) / app.use("/path", fn)) — global or path-prefixed cross-cutting concerns. Router-level (router.use(fn)) — the same mechanics scoped to a Router instance. Error-handling ((err, req, res, next) — four parameters, detected by fn.length). Built-in/third-party — express.json/urlencoded/static, plus the ecosystem (helmet, cors, morgan, multer, compression). Router-level middleware's genuine value is protection by construction: adminRouter.use(requireAdmin) means every route mounted on that router is protected structurally — a developer adding adminRouter.get("/new-thing", …) next year cannot forget the guard, because the guard belongs to the router, not to each route. That converts a discipline problem (remember to add auth) into a design property, which is 9.3.6's OCP applied to security: the safe path is the default path. Same reasoning powers per-router body limits, per-router rate limits, and per-router error semantics.
StaffA team reports intermittent requests that never return — no error, no log, no timeout on the server side, and eventually the process stops accepting connections. Diagnose and prescribe.
The symptom triad (no response, no error, connections accumulating) is hung middleware: some path neither responds nor calls next(), so the request object lives forever, its socket stays open, and the process eventually hits its connection or file-descriptor limit (EMFILE — 3.8.7). Root causes, in likelihood order:
(1) an async middleware whose promise rejected — in Express 4, an unhandled rejection inside async (req, res, next) => {…} never reaches next(err), so the chain simply stops (the framework has no idea anything happened); (2) a conditional path that forgets next() (if (cached) return res.json(hit); /* else … falls through with no next() */); (3) an await on something that never settles (a DB call without a timeout, a lock never released — 9.5.2).
Diagnose: add a request-timing middleware that logs on res.on("finish") and registers a timer logging requests still open after N seconds with their route and stack context (3.8.7's AsyncLocalStorage carries the traceId); count open connections (server.getConnections) as a metric — the leak's shape is visible immediately.
Prescribe, structurally: wrap every async handler so rejections become next(err) (Express 5 does this natively; on 4, express-async-errors or an explicit asyncHandler wrapper — [9.9.3]); set server-side timeouts (server.requestTimeout, server.headersTimeout) so hung requests die rather than accumulate; add the open-request metric to dashboards with an alert; and lint for handlers that neither return a response nor call next on every branch. The lesson to record: Express's pipeline has no supervisor — a layer that goes silent stalls its request forever, so timeouts and async-error wrapping are essential infrastructure, not polish.
Flashcards
FlashExpress = ?
Raw http + routing + middleware pipeline + req/res sugar. app is itself a handler function. No architecture supplied — that's yours.
Flashnext() outcomes
next() → continue · respond without it → short-circuit · next(err) → error pipeline · neither → request hangs silently (the #1 bug).
FlashOrder is the security model
Registration order = execution order. Auth below routes = public routes. Parser below handlers = undefined bodies. Error handler always last.
FlashFour middleware types
Application-level, router-level (protection by construction), error-handling (4 params via fn.length), built-in/third-party.
FlashThe three built-ins
express.json (consumes stream; limit = DoS control) · express.urlencoded (extended = qs nesting) · express.static (prefer Nginx/CDN in prod).
Scenario Drill
DrillYou're handed an Express app's app.js with this sequence: routes → express.json() → cors() → authenticate → errorHandler → helmet(). Nothing obviously fails in local testing. List every bug this ordering causes, in the order a user would encounter them, and give the corrected sequence with justification per layer.
Bugs, in encounter order. (1) Every route is public — authenticate is registered after the routers, so no route ever passes through it; local testing with a valid token hides this completely (the token is simply ignored). This is a total authorization bypass, and it is the first thing a security review would find. (2)
req.body is undefined in every handler — express.json() runs after the routes, so POST/PUT handlers see no body; developers "fix" this per-handler by reading the raw stream or by adding a second parser inside a router, breeding dialects. (3) CORS preflights fail — cors() below the routes means OPTIONS requests reach the router (which has no OPTIONS handler) and 404 without the Access-Control-Allow-* headers; the browser reports an opaque CORS failure while curl and Postman work perfectly (the classic "works in Postman" report — [6.10]'s question, produced here by ordering). (4) No security headers anywhere — helmet() is last, so it never runs before a response is sent; X-Content-Type-Options, HSTS, and CSP are absent on every response including errors. (5) The error handler is not last — errors raised inside helmet() (or anything registered after it) bypass it, and more subtly, errorHandler sitting above helmet means error responses definitely have no security headers. (6) No 404 handler — unmatched routes fall to Express's default, returning an HTML error page from a JSON API (content-type mismatch that breaks clients' error parsing — 9.6.1's one-envelope rule violated by omission). Corrected sequence with justification: helmet() first (headers on every response, including 404s and errors); cors() second (preflights answered before anything can reject them, and CORS headers present on error responses too — otherwise browsers hide your 500s); express.json({ limit: "100kb" }) third (bodies available to everything downstream; the limit caps memory before parsing — a DoS control); request-context/logging fourth (traceId established before anything can fail — 3.8.7); rateLimit fifth (cheap rejection before expensive identity work); authenticate sixth (identity), with per-router authorize inside the routers (protection by construction); routers seventh; notFoundHandler eighth (JSON envelope for unmatched paths); errorHandler last, four arguments (the single funnel for every failure — [9.9.3]). The transferable rule to state in the PR: in Express, the order of app.use calls is a security and correctness specification — review it as one.