awesome-everything RU
↑ Back to the climb

Backend Architecture

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

Crux 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.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at middle altitude — in the sky
◷ 14 min

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).

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.

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.

Connected lessons
appears again in185
Continue the climb ↑Inversion of control: how dependencies reach a class
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.