Express & Middleware

Lesson 1 of 5

What Express Actually Adds

"Simple things should be simple, complex things should be possible." — Alan Kay

Part IV built a server by hand: a parser, a router, body handling, response composition. Every one of those was real work, and every one of them is work you would rather not repeat on your next project. Express is the layer that packages them — and the reason to have built them first is that you can now say precisely what it adds, which is a much shorter list than its reputation suggests.

Here is the whole of it. Express gives you routing (Part IV ch3), a small set of request and response helpers (ch5), mountable sub-applications, and one genuinely new idea: middleware, a composition pattern for the request lifecycle. Body parsing is included but is itself just middleware. Sessions, authentication, validation, database access and templating are not included at all.

That is a thin layer, and Express has always been proud of it. What makes it worth a Part rather than a paragraph is that the one new idea is unusually load-bearing: middleware is how authentication, logging, compression, CORS, rate limiting and error handling all attach to a request, and getting its ordering and control flow right is most of what makes an Express codebase pleasant or awful.

There is a second reason for care. Express 5 changed things that Express 4 tutorials still teach, including route pattern syntax that now throws at startup and error handling that used to hang. Everything in this chapter was measured against Express 5.2.1, and the differences are called out where they bite.

The big question

An async route handler throws. Does your error middleware run?

In Express 5, yes. In Express 4, no — the request hangs until it times out. Measured on 5.2.1: an async handler that throws returns 500 from the error middleware, exactly like a synchronous throw.

This is the single most consequential change between the versions, and the Express 4 behaviour is worth understanding because it explains a decade of Node advice. Express 4 called your handler and, if it threw synchronously, caught it. An async function does not throw synchronously — it returns a rejected promise, which Express 4 ignored, so next(err) was never called, no response was ever written, and the connection sat open until requestTimeout. That is why express-async-errors, express-async-handler and hand-rolled asyncHandler wrappers exist in so many codebases: they are all workarounds for one missing .catch(), and in Express 5 they are dead weight.

An Express app is a function

The least appreciated fact about Express is how little it changes about the interface.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Three consequences of that shape, all of which come up in real code.

An Express app can be mounted inside another Express app, because a (req, res, next) function is exactly what app.use accepts. Sub-applications are not a special feature; they fall out of the interface.

An Express app can be handed to http.createServer, and to anything else expecting a Node handler — a serverless adapter, a test harness, a http2 server. app.listen() is a convenience that creates the server for you.

Middleware and route handlers are the same kind of thing. The only difference is whether a method and path were specified. That uniformity is why app.use(express.json()) and app.get("/x", handler) compose without any special casing.

Everyday analogy

An airport security queue. A bag goes through a series of stations — check-in, document check, scanner, sometimes a manual search — and each station can pass it on, send it down a different lane, or stop it entirely. No station knows the whole route; each knows only its own job and how to hand the bag onward.

That is middleware, and the analogy carries the important detail too: the order of the stations is the design. Putting the scanner before the document check is not a style preference, it is a different security policy. The most common Express bug is exactly this — authentication registered after the route it was supposed to protect, so the bag reaches the gate before anyone checks the passport.

Middleware: the one genuinely new idea

Everything else Express does, Part IV built. This is the part that was not there.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Four rules govern that loop, and every Express bug in this area is a violation of one.

A layer must call next() or end the response. Doing neither hangs the request — no error, no response, until a timeout. This is the same failure as Part IV's handler that forgot res.end(), arriving through a different door.

Calling next() more than once is a bug. It advances the chain twice, which usually produces ERR_HTTP_HEADERS_SENT further along. if (bad) return next(err) — with the return — is idiomatic for exactly this reason, and it is the same missing-return shape as Part II's callbacks.

next(err) skips to error handling, jumping over every remaining normal layer. That is what makes short-circuiting work.

Error middleware takes four arguments, and that is how Express distinguishes it — by fn.length. It is an unusual piece of API design: the arity of your function is load-bearing, so a four-argument error handler written with three arguments silently becomes normal middleware and never runs.

A little history

Express was released by TJ Holowaychuk in 2010, a year after Node itself, and it took its middleware idea from Ruby's Rack and Python's WSGI — both of which had already established that a web application is a function from request to response, and that reusable behaviour composes by wrapping. Express's contribution was the mutable (req, res, next) signature, which is easier to write than a wrapping combinator and considerably harder to reason about.

For most of Express's life the middleware layer was a separate package called Connect, which is why so much middleware still describes itself as "Connect-style". Express 4 absorbed it and, in the same release, moved every body parser and static file server out of core into separate packages — a deliberate move to a smaller core that the ecosystem then partly reversed, since express.json() came back in 4.16.

Express 5 took ten years to arrive, and the delay is itself informative: the breaking changes were small and the ecosystem was enormous. What finally shipped is mostly two things — automatic handling of rejected promises from handlers, and a new version of path-to-regexp with stricter pattern syntax. The second is why an Express 4 application does not necessarily start under Express 5, which the last section covers.

The helpers

The res helpers are small, and one of them does something surprising.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Those content types are not invented; they are what Express 5.2.1 actually returns. Three things follow.

res.send with a string sets text/html. For an API returning a plain-text message, that is wrong and it is silent. res.json() for data and res.type("text/plain").send(...) for text are the honest forms. It matters for the reason Part IV ch5 gave: without nosniff, HTML is executed.

res.status() is chainable and sets nothing else. res.status(404) alone sends no response — a surprisingly common bug, because the line looks complete.

res.json sets the content type and serialises, which is the whole of it. There is no magic; it is the four lines above.

On the request side, Express adds req.params from the route pattern, req.query parsed from the query string, and req.body only if a body-parsing middleware ran. Two measured details worth carrying: req.params values are always strings — /users/42 gives "42" — and req.query produces an array for repeated keys, so ?a=1&a=2 gives { a: ["1", "2"] }. Code expecting a string gets an array whenever a client repeats a parameter, which is a validation bug waiting for its first user.

The engineer's notebook

Three questions when reading an Express application.

Does every path through this middleware call next() or end the response? A branch that does neither hangs the request with no error at all.

Is this registered before or after the routes it affects? Order is the design. Authentication after a route does not protect it.

Does the error handler have exactly four parameters? Express identifies it by arity. Three parameters makes it ordinary middleware that never sees an error.

Routers are mountable apps

The composition idea again, one level up.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

That path rewriting is the whole mechanism, and it has three practical consequences.

A router is reusable because it is prefix-agnostic. It declares / and /:id; where those live is the mounting application's decision, so the same router can serve /api/v1/users and /api/v2/users.

req.path is relative and req.originalUrl is absolute. Middleware that builds links or logs URLs wants originalUrl; routing logic wants path. Using the wrong one produces URLs missing their prefix — a bug that only appears once something is mounted, which is usually after the code was written and tested unmounted.

Middleware mounted with a path only runs for that prefix. app.use("/admin", requireAdmin) is scoped, which is the clean way to apply a policy to a subtree rather than repeating a guard on every route.

In the real world

The most common Express incident is middleware registered after the route it was meant to protect. Authentication is added with app.use(requireAuth) at the bottom of the file, or after a router mount, and every route above it is public. Nothing errors, every test that exercises the authenticated path passes because it sends a token anyway, and the endpoint is simply open. The Express model makes this easy to do and gives you no way to detect it, because "which middleware protects this route" is not something the framework can answer — it is implied by line order across several files.

The second is the hanging request. A middleware has a branch — a cache hit, a validation shortcut, an early return — that neither calls next() nor sends a response. There is no error and no log line; the request occupies a connection until the server's request timeout, which by Part IV's measurement defaults to five minutes. Under load these accumulate and the symptom is connection exhaustion with low CPU, which looks nothing like a routing bug.

The third is specific to upgrading: Express 5 rejects route patterns that Express 4 accepted. Measured on 5.2.1, app.get("/old/*") throws Missing parameter name at startup, and "/opt/:id?" throws Unexpected ?. Those are the two most common patterns in older codebases, and because they throw at registration rather than at request time, the application does not start at all — which is the good version of this failure.

What Express does not add, and what version 5 changed

The list of absences is short and worth stating explicitly.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

The version differences, all measured on 5.2.1, matter enough to be explicit:

// EXPRESS 4 -> 5, the changes that break existing code.

// 1. Async errors are now forwarded automatically.
app.get("/x", async (req, res) => { throw new Error("boom"); });
//    Express 4: request HANGS until timeout — next(err) is never called
//    Express 5: error middleware runs, exactly like a sync throw
//    => asyncHandler / express-async-errors wrappers are now unnecessary

// 2. Route patterns are stricter (path-to-regexp v8). These now THROW at startup:
app.get("/old/*", h);        // Error: Missing parameter name at index 6
app.get("/opt/:id?", h);     // Error: Unexpected ? at index 8

// 3. The replacements:
app.get("/new/*splat", h);   // req.params.splat is an ARRAY: ["a","b","c"]
app.get("/opt/:id{/:rev}", h);  // braces mark an optional group

The first change is the one worth internalising. A decade of Node advice about wrapping async handlers exists because of one missing .catch(), and if you are reading an Express tutorial that tells you to install express-async-errors, you are reading about Express 4. The second is why an upgrade is not free: the patterns fail at registration, so the process does not start, which at least makes them impossible to miss.

Common Mistakes

  1. Registering authentication after the routes it protects. Order is the design, and nothing warns you.

  2. A middleware branch that neither calls next() nor responds. The request hangs until a timeout, with no error.

  3. Calling next() twice. The chain advances twice, usually surfacing later as ERR_HTTP_HEADERS_SENT.

  4. next(err) without return. Execution continues into the success path as well.

  5. An error handler with three parameters. Express identifies error middleware by arity; three makes it ordinary middleware.

  6. res.status(404) with nothing after it. It sets a code and sends nothing.

  7. res.send(string) for an API. It sets text/html, not text/plain.

  8. Assuming req.query values are strings. A repeated key gives an array.

  9. Using req.path where req.originalUrl is meant. Mounted routers see a rewritten path.

  10. Following Express 4 advice on Express 5. Async wrappers are unnecessary, and /* and :id? patterns throw at startup.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
What does Express add on top of Node's http module?
Model answer — Less than people assume. It adds routing, so I can write app.get("/users/:id", handler) instead of matching req.url myself; a few request and response helpers like res.json, res.status and req.params; mountable routers so a group of routes can be developed independently and attached under a prefix; and the one genuinely new idea, middleware — a chain of functions that each receive the request and either handle it or pass it along. Body parsing is bundled as middleware rather than being part of the framework proper. What it does not add is sessions, authentication, validation, database access or templating; all of those are separate packages. The thing that surprised me is that an Express app is a (req, res, next) function, so it plugs straight into http.createServer and can even be mounted inside another Express app — sub-applications are not a special feature, they fall out of that signature.
Mid
An async route handler throws and the request hangs. What is happening?
Model answer — That is Express 4. Express 4 calls the handler and catches a synchronous throw, but an async function does not throw synchronously — it returns a rejected promise, and Express 4 ignored the return value. So next(err) is never called, the error middleware never runs, no response is written, and the connection stays open until the server's request timeout, which defaults to five minutes. That is exactly why express-async-errors and hand-rolled asyncHandler wrappers are so common — they all just wrap the handler and attach a .catch(next). In Express 5 this is fixed: a rejected promise from a handler is forwarded to error middleware automatically, and I confirmed it behaves the same as a synchronous throw. So the fix is either upgrade, or wrap every async handler. The related thing I would check is whether the error middleware has four parameters, because Express identifies it by function arity — an error handler written with three parameters silently becomes ordinary middleware and never sees the error.
Senior
You are auditing an Express codebase for security. What does the middleware model make hard to verify?
Model answer — Which policies actually apply to which routes. Express expresses that implicitly, through the order in which things are registered across several files, so "is this endpoint authenticated" is not a question the framework can answer — you have to read the registration order of the whole application and mentally simulate it. That makes the most common serious bug, authentication registered after the routes it was meant to protect, both easy to write and hard to detect: nothing errors, and tests pass because they send a token anyway. What I would do about it is stop relying on ordering for anything security-relevant. Mount protected routes under a router that has the guard applied at the mount point, so the association is structural rather than positional, and the route cannot be reached without passing through it. Then add a test that enumerates the route table and asserts every route is either in the public allowlist or behind the guard — that turns an implicit property into an explicit one. I would also check for middleware branches that neither call next() nor respond, since those hang a connection for the full request timeout with no error, and under load that is a denial of service that looks like a capacity problem.
Company lens — how the same topic gets asked
micro1Practical: write middleware, and explain what next() does.
GooglePrecision: why an app is a function, and how error middleware is identified.
MetaDebugging: a hanging request, or an async handler whose error vanishes.
AmazonJudgement: what ordering makes hard to audit, and how to make it structural.

Hands-on

Hands-on

Four exercises building Express's core. Exercise 2 is the dispatch loop — next(), short-circuiting, and four-argument error layers.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Summary

  • An Express app is a (req, res, next) function, which is why it plugs into http.createServer and why sub-applications need no special support.
  • Express adds routing, helpers, mountable routers and middleware — and middleware is the only genuinely new idea.
  • A middleware layer must call next() or end the response. Neither means the request hangs with no error.
  • next(err) skips to error handling, and error middleware is identified by having four parameters.
  • Order is the design. Authentication registered after a route does not protect it, and nothing warns you.
  • res.send(string) sets text/html — measured — which is almost never what an API wants.
  • req.params values are strings; repeated req.query keys become arrays.
  • A mounted router sees a rewritten req.path, with req.baseUrl as the prefix and req.originalUrl untouched.
  • express.json() bounds the body at 100kb by default and returns 400 on invalid JSON, 413 when too large.
  • Express 5 forwards async handler rejections automatically, and rejects /* and :id? patterns that Express 4 accepted.

This chapter treated middleware as a mechanism. The next treats it as a pattern — what belongs in a middleware and what does not, how to write one that is composable rather than merely reusable, the difference between middleware that decorates a request and middleware that makes a decision about it, and the ordering constraints that emerge once a real application has fifteen of them. The mechanism is thirty lines; using it well is the part that takes judgement.

Editorial reflection

The design decision I find most questionable in Express is identifying error middleware by function arity. fn.length === 4 means the number of parameters you declare changes what your function is, so an error handler written with three parameters — perhaps because a linter flagged an unused next — silently becomes ordinary middleware and never runs. There is no error, no warning, and the symptom is that errors stop being handled. Using a distinct registration method would have cost nothing and removed the entire class.

What I think Express got genuinely right is being a function. An app being (req, res, next) means mounting, testing, adapting to serverless and composing sub-applications all work without any of them being designed for — they are consequences of the signature rather than features. That is the same structural-interface property that made stream composition free in Part III, and it keeps paying out in the same way: an abstraction defined by shape composes with things its author never anticipated.

The Express 5 async change is worth reflecting on as an ecosystem story. For roughly ten years, the recommended fix for a framework's missing .catch() was for every application to install a package that added it. Thousands of codebases carry that workaround, a generation of tutorials teach it, and it was ultimately about four lines in the framework. It is a good reminder that the cost of a small gap in a widely used library is not paid by the library — it is paid, repeatedly and in aggregate, by everybody downstream, which is exactly the argument for fixing small things in popular code even when a workaround exists.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.