open atlas
↑ Back to track
Backend Architecture BE · 02 · 02

Writing middleware: signatures, next(), and the three framework models

A middleware is a function with a contract: read the request, optionally act, then call next() or terminate. The contract differs across Express, Koa, and Fastify — and the most common production bugs come from breaking it in the same three ways.

BE Middle ◷ 14 min
Level
FoundationsJuniorMiddleSenior

A middleware that adds a request ID works perfectly. A copy of it that also calls an async audit log starts throwing Cannot set headers after they are sent under load — but only sometimes. The bug is not the audit call. It is that the async error escapes the middleware, the framework’s default handler responds, and then the original code path responds too. Two responses, one socket. The middleware contract was broken in the most common way there is.

The Express signature

In Express, a middleware is (req, res, next) => {}. It has exactly three legal endings:

  • Pass through — call next() to hand control to the next middleware.
  • Terminate — send a response (res.json(...), res.end()) and do not call next(). This short-circuits the chain.
  • Fail — call next(err) to skip ahead to error-handling middleware.

The two failure modes are doing both (send a response and call next(), leading to a double-send) or neither (no response, no next() — the request hangs until it times out). Together these three endings form a complete, closed contract — and every production bug in middleware traces back to breaking exactly one of them.

Error middleware is a different shape

Express recognizes error-handling middleware by arity: it must declare four parameters, (err, req, res, next). Drop the unused next and Express treats it as ordinary middleware and never routes errors to it — a silent, infuriating bug. Error middleware also must be registered last, after all routes, because the chain reaches it only via next(err) from earlier layers.

Why this works

Why did Express 5 change async error handling? In Express 4, a route handler that returned a rejected promise did not automatically reach error middleware — an unhandled rejection escaped, which is exactly the double-response bug in the Hook. Teams wrapped every async handler in an asyncHandler helper to catch and forward. Express 5 makes route handlers and middleware that return a promise call next(err) automatically on rejection, so the wrapper is no longer needed. The four-argument error-middleware signature is unchanged; only the forwarding became automatic.

Koa: the async onion

Koa replaces the three-arg callback with async (ctx, next) => {} and a true onion model. Code before await next() runs on the way in; code after it runs on the way out, in reverse order. This makes wrap-around timing trivial:

async (ctx, next) => {
  const start = Date.now();      // inbound
  await next();                  // run everything inside
  ctx.set('X-Time', `${Date.now() - start}`); // outbound
}

Because it is promise-based, a throw anywhere propagates naturally to an upstream try/catch middleware — error handling is just a middleware that wraps the rest in try/catch around await next().

Fastify: hooks, not one primitive, plus encapsulation

Fastify splits the single middleware idea into named lifecycle hooks that fire in a fixed order:

onRequest → preParsing → preValidation → preHandler → handler → onSend → onResponse

This lets you reject early at onRequest (before body parsing — the cheapest rejection point) or run logic at preHandler once the validated body exists. The bigger difference is encapsulation: hooks (and decorators) registered inside a plugin apply only to that plugin’s routes, not globally. Express middleware, by contrast, leaks down the whole chain from where it is mounted. Encapsulation is why large Fastify apps stay scoped where large Express apps accrete global middleware.

The ordering choice is not free, and it is where the chain-length-vs-latency tradeoff bites. Each middleware is a function call plus whatever work it does; a lean layer (set a header, attach a request ID) costs well under a microsecond, but every layer is paid on every request whether or not it is needed. A global body-parser mounted before the router runs JSON.parse on bodies for routes that have none; an auth middleware mounted globally hashes and verifies a token even for a public health check. Rejecting at the wrong point compounds it: reject an unauthenticated request at onRequest and you skip body parsing, validation, and the handler — reject it inside the handler and you have paid for all of them first. The discipline is to order cheap, high-rejection-rate layers first and scope expensive layers narrowly. In practice this is why Express benchmarks at roughly 11–15k req/s while Fastify reaches 30k+ on the same hardware: not raw routing speed alone, but Fastify’s encapsulation keeping each route’s chain short instead of forcing every request through one fat global stack.

ModelSignatureWrap-aroundScope
Express(req, res, next)Manual; error mw needs 4 argsGlobal from mount point
Koaasync (ctx, next)Native onion via await next()Global stack
FastifyNamed hooksHooks at fixed lifecycle pointsEncapsulated per plugin
Quiz

An Express error handler `(err, req, res) => {...}` never receives errors even though earlier code calls next(err). Why?

Quiz

What is the root cause of a `Cannot set headers after they are sent` error from a middleware?

Quiz

Why does Fastify's encapsulation matter compared to Express middleware?

Recall before you leave
  1. 01
    What are the legal endings of an Express middleware, and what are the two ways to break the contract?
  2. 02
    How does Express recognize error-handling middleware, and what changed in Express 5?
  3. 03
    Contrast the Koa onion model with Fastify's hooks and encapsulation.
Recap

A middleware is a function bound by a contract: read the request, optionally act, then do exactly one of pass control with next(), terminate by sending a response, or forward an error with next(err). Breaking it produces the two classic bugs — responding twice (“headers already sent”) or responding never (a hang). Express identifies error middleware by its four-parameter arity and, since version 5, auto-forwards rejected promises so the asyncHandler wrapper is obsolete. Koa expresses the same idea as a native async onion where logic before and after await next() runs inbound and outbound. Fastify breaks middleware into fixed lifecycle hooks and adds encapsulation, so plugin-scoped hooks do not leak globally the way Express middleware does. With the request-axis mechanics in hand, the next lesson turns to the wiring axis: inversion of control and how dependencies actually reach a class. Now when you see a Cannot set headers after they are sent crash or a middleware that silently swallows errors, you know exactly which part of the contract broke — and where to look first.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons
appears again in188

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.