Middleware looks like the perfect Node.js abstraction. A function that receives the request, optionally does something, and either passes control along or sends a response. Three lines, one cross-cutting concern handled. You write a few of them, you sprinkle them across routes, and life is good.
By month six, the same codebase has nine middlewares per request, three of them mutate req in ways nobody documents, the order matters in a way that surprises every new engineer, and every weird bug ends with "did the middleware run before the validator or after?" The pattern didn't fail — your use of it did.
This article is about avoiding that. The small set of rules that keep middleware chains debuggable, the patterns that scale past one team, and the things to leave out of middleware entirely.
What Middleware Actually Is
In Express, middleware is (req, res, next) => void. In Fastify, it's a hook with a similar shape. In Koa, it's (ctx, next) => Promise<void> with composable async semantics. The mechanics differ slightly, but the contract is the same: receive a request context, do something with it, decide whether to keep going.
// Express
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`)
next()
})
The power is composition: you stack middleware in order, each one sees the request before the route handler does, and each can short-circuit (by sending a response and not calling next()). The trap is also composition — a chain of nine middlewares that each touch req becomes an implicit data-flow graph that nobody owns.
Ordering Is The First Rule
Middleware runs in registration order. That's the entire mental model, and it's the source of more bugs than anything else in the chain.
A working order that's earned its keep across many production codebases:
import express from 'express'
import helmet from 'helmet'
import cors from 'cors'
import { pinoHttp } from 'pino-http'
import { rateLimit } from 'express-rate-limit'
const app = express()
// 1. Observability — runs first so it sees everything
app.use(pinoHttp({ logger }))
// 2. Security headers — cheap, safe, runs early
app.use(helmet())
// 3. CORS — must run before route handlers
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') }))
// 4. Rate limiting — before expensive work
app.use(rateLimit({ windowMs: 60_000, max: 100 }))
// 5. Body parsing — only what you need
app.use(express.json({ limit: '1mb' }))
// 6. Request-scoped context (auth, tenant)
app.use(authMiddleware)
app.use(tenantContextMiddleware)
// 7. Routes
app.use('/api/users', usersRouter)
// 8. Error handler — last, with four arguments
app.use((err, req, res, next) => {
logger.error({ err, req: { url: req.url } }, 'unhandled')
res.status(err.status ?? 500).json({ error: err.message })
})
Read this top-to-bottom and the request lifecycle is obvious. Reorder one thing and the bugs aren't always loud — a CORS preflight handled after auth still works; a CORS preflight handled after a body-parser that 413s on large requests doesn't.
The Four-Argument Error Handler Is A Real Thing
Express identifies error-handling middleware by arity. A function with four parameters (err, req, res, next) is treated as an error handler and only runs when something earlier called next(err). Drop the first parameter and Express won't recognise it. This is one of the most surprising parts of the framework, and it's the reason your error handler "doesn't fire" half the time.
// ❌ Three params — Express skips this for errors
app.use((req, res, next) => { /* ... */ })
// ✅ Four params — Express invokes this on next(err)
app.use((err, req, res, next) => { /* ... */ })
Since Express 5 (released October 2024), thrown errors and rejected promises in async route handlers are automatically forwarded to the error chain. In Express 4, you wrap async handlers with express-async-errors or a small asyncHandler helper. If you're still on 4 and forgot the wrapper, your error chain quietly never fires.
What Belongs In Middleware (And What Doesn't)
Middleware earns its keep when the same concern shows up across many routes:
- Cross-cutting reads. Logging, tracing, request IDs, parsing.
- Cross-cutting checks. Auth, rate limits, content negotiation.
- Request-scoped context. Attaching a tenant, a correlation ID, the resolved user.
Middleware becomes a mess when it does:
- Business logic. "Send a notification when the request body has X." That's a service, not a middleware.
- Per-route validation. Validating the shape of one endpoint's body in a global middleware means every other endpoint pays the cost. Validate inside or just before the handler.
- Side effects nobody mentions. A middleware that quietly writes to the database is a debugging horror story waiting to happen.
The smell test: if removing the middleware would only break one route, it shouldn't be middleware. Move it into the route, or into a per-route schema validator that runs immediately before the handler.
Request-Scoped Context With AsyncLocalStorage
Every Node.js backend eventually wants the same thing: "give me the current user / tenant / correlation ID anywhere in the call stack, without passing it through every function". For a long time the answer was "pass it as a parameter or attach to req". Modern Node has a better tool: AsyncLocalStorage from node:async_hooks.
import { AsyncLocalStorage } from 'node:async_hooks'
interface RequestContext {
requestId: string
userId?: number
tenantId?: number
}
export const requestContext = new AsyncLocalStorage<RequestContext>()
// middleware that opens a context for the lifetime of one request
export function contextMiddleware(req, res, next) {
const ctx: RequestContext = {
requestId: req.headers['x-request-id'] ?? crypto.randomUUID(),
userId: req.user?.id,
tenantId: req.user?.tenantId,
}
requestContext.run(ctx, () => next())
}
Now anywhere downstream — a service, a query, a logger — can read it without taking it as a parameter:
const ctx = requestContext.getStore()
logger.info({ requestId: ctx?.requestId }, 'user fetched')
This is the same primitive Pino, OpenTelemetry, and most modern Node frameworks use under the hood for request correlation. It works across await boundaries, which is the magic — node tracks the async chain for you.
Async Middleware Without The Footguns
In Express 4, an async middleware that throws is a silent disaster:
// ❌ Express 4: this rejection is unhandled
app.use(async (req, res, next) => {
await someAsyncCheck(req)
next()
})
Three workarounds:
- Express 5 handles this automatically — upgrade if you can.
express-async-errors—import 'express-async-errors'once at the entry point and Express 4 forwards rejections to the error chain.- A wrapper helper —
asyncHandler(fn)that callsfn(req, res, next).catch(next).
In Fastify and Koa, this isn't an issue — both await middleware natively and forward rejections to the error path.
A Small Set Of Habits
Five rules that consistently keep middleware healthy:
- One concern per middleware. "Authenticate" is one. "Authenticate, fetch the org, attach to req" is three.
- Scope it tightly. Per-route or per-router middleware (
router.use(checkAdmin)) is better than global when only some routes need it. - Keep the chain visible. All
app.use()calls in one file, top ofapp.ts. No registering middleware from inside random services. - Make context attachment explicit. Use
AsyncLocalStorageor augment Express'sRequesttype soreq.useris typed and discoverable. - Document the order rule. A short comment in
app.tssaying "this order matters because" beats an oral tradition every time.
A One-Sentence Mental Model
Middleware is the request lifecycle written as a list. The list runs top-to-bottom, each entry has one job, and the order is part of the API. When the list reads cleanly, every weird bug becomes a quick git blame away from an obvious cause. When the list is a tangle, every bug is a debugging session. Pick the version that ages well.






