Middleware Ordering Is a Correctness Decision
The chain is a dependency graph flattened into a list; reordering it does not tidy the same behaviour, it produces different behaviour.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
Why does the same set of middleware behave differently when you change the order?
The service needs a correlation id, access logs, CORS, a body limit, body parsing, authentication, rate limiting, request validation and an error boundary. All nine are written. Now they have to be arranged.
Add each app.use() as the feature that needed it was built. The order is roughly historical, which is fine, because each one does its own job independently.
The body parser runs before webhook signature verification, so the raw bytes are gone and every signature check fails against a payload that was re-serialised with different key order or whitespace (Webhook Signature Verification).
- The body parser runs before webhook signature verification, so the raw bytes are gone and every signature check fails against a payload that was re-serialised with different key order or whitespace (Webhook Signature Verification).
- CORS is registered after authentication, so an expired token produces a 401 with no
Access-Control-Allow-Originheader; the browser reports an opaque CORS error and the frontend team spends a day looking for a CORS bug that is an auth bug. - The correlation-id middleware is below the logger, so every access log line has an empty
correlation_idand none of the traces join up (Correlation Ids That Survive Every Hop). - The body-size limit is enforced after the body has been buffered, so a 50 MB upload has already been read into memory before it is rejected (Request Bodies and Streaming).
- The error handler is registered before the routes, so nothing routes through it and unhandled errors fall through to the framework default — which in several frameworks includes a stack trace (Not Leaking Your Internals).
What is actually happening
- Each middleware requires some facts to already be true and provides some facts for the ones below it. Authentication requires a parsed
Authorizationheader and provides a principal. Rate limiting requires a key — an IP, or a principal if it wants one. Logging requires a correlation id if the line is to be useful. - Draw those requires/provides edges and you have a directed graph. A valid ordering is any topological sort of it. Most chains have several valid orderings and a great many invalid ones, and nothing in the framework checks.
- A second constraint cuts across the first: cost. Among orderings that satisfy the dependencies, prefer the one that rejects the most requests for the least work. That is what makes ordering an availability decision as well as a correctness one (Authenticate First, or Rate-Limit First?).
- A third constraint is coverage. A middleware only protects what passes through it, so anything that short-circuits above it — a static file handler, a cache hit, an early CORS preflight response — escapes everything below.
- Finally, the outbound phase reverses the order. A middleware that needs to modify the response must be registered *outside* whatever writes it, which is the opposite of the intuition that "closer to the handler means more control" (The Middleware Pipeline).
- Some frameworks remove part of this problem by defining phases rather than a single list, but the dependency graph does not go away — it just gets expressed in phase names instead of line order.
A dependency graph flattened into a list
The reason ordering feels arbitrary is that the constraints are never written down. Each middleware has a precondition and a postcondition, and once those are explicit the valid orderings are obvious and the invalid ones are impossible to argue for.
The diagram is the argument. Every arrow is "this must already be true", and the list you write in your application file must be a linear extension of it.
A defensible default order, and why each position is what it is
This is a starting point, not a universal. It satisfies the dependency graph above, rejects cheaply before expensively, and puts every response-touching concern outside whatever writes the response. Where your requirements differ — no browser clients, no webhooks, an edge that already rate-limits — positions change, and the reasoning is what transfers.
Read the failsBy column as the cost of moving that entry one step in the wrong direction.
- 11. Error boundary
Catches anything thrown below and maps it to a response with a correlation id.
fails by Placed lower, it misses errors from the middleware above it, which then produce framework defaults (The Error Boundary).
- 22. Correlation id
Accepts an inbound id or generates one; puts it on the request context and the response.
fails by Placed below the logger, every line above it is unjoinable (Correlation Ids That Survive Every Hop).
- 33. Access log + metrics
Starts a timer inbound; writes method, route template, status and duration outbound.
fails by Placed below a short-circuiting control, rejected requests are never logged — exactly the ones you need.
- 44. Security headers + CORS
Answers preflights; attaches headers to every response including errors.
fails by Placed below authentication, 401s reach browsers without CORS headers and appear as CORS failures.
- 55. Body size limit
Bounds how many bytes will be read at all.
fails by Placed below parsing, the bytes are already in memory when the limit rejects them (Request Bodies and Streaming).
- 66. Coarse rate limit (IP + route class)
Sheds obvious floods before any parsing or crypto happens.
fails by Placed below authentication, an unauthenticated flood still costs verification and a user lookup (Authenticate First, or Rate-Limit First?).
- 77. Raw-body capture / webhook exemption
Preserves the exact bytes for routes that verify signatures.
fails by Placed below the parser, the stream is consumed and signatures can never verify (Webhook Signature Verification).
- 88. Body parse
Materialises JSON or form data into a typed value.
fails by Parsing every request including those about to be rejected, which is work done for nothing.
- 99. Authenticate
Establishes the principal, or rejects with 401.
fails by Applied per-route rather than globally, so a new route ships open (Authentication in a Backend).
- 1010. Per-principal quota
Enforces the customer-visible limit now that identity is known.
fails by Used as the only limiter, leaving the unauthenticated surface uncapped.
- 1111. Route + per-route validation
Matches the handler and validates against that route's schema (Transport Validation).
fails by Global schema validation that cannot know the route's shape.
- 12
Positions 6 and 10 are the same concern with two different keys. Treating rate limiting as one box that must be placed once is the mistake this order is designed to avoid.
Reordering failures you will actually meet
Every row here has shipped. None of them raised an error at the point of the mistake, and most of them were reported by someone other than the team that caused them — a mobile client, a frontend engineer, a payment provider's support desk.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Body parser registered above webhook routes | Every incoming webhook fails signature verification | The raw bytes were consumed and re-serialisation changes them | Capture the raw body before parsing, or exempt webhook paths from the parser (Inbound Webhooks) |
| CORS registered below authentication | Browser reports a CORS error; the real status was 401 | The short-circuited 401 never reached the CORS middleware's outbound phase | Register CORS outside authentication so every response, including errors, carries the headers |
| Correlation id registered below the logger | Log lines with empty ids; traces that do not join | The id did not exist when the logger read the context | Generate the id in the outermost useful position and propagate it (Request Context Propagation) |
| Error handler registered before the routes | Unhandled errors return framework defaults with stack traces | Nothing routes through a handler registered above the routes in Express | Register the error middleware last, after every route and router (The Error Boundary) |
| Body limit applied after parsing | Memory spikes and OOM restarts on large uploads | The payload was buffered before the limit was consulted | Bound the read itself; reject while streaming, not after (Request Bodies and Streaming) |
| Auth middleware applied per-route | One new endpoint is publicly readable | A route was added without its middleware array | Authenticate globally; maintain an explicit public allowlist (Where the Check Belongs) |
| Compression registered inside a header-setting middleware | Intermittent headers already sent errors | The outbound phase ran after the response had been written | Move response-touching middleware outside anything that writes the response |
How to build it
Most important first.
- Write the whole chain in one file, in order, with a one-line comment per entry stating what it requires and what it provides. That comment block is the design document for the pipeline and it fits on a screen.
- Order by: error boundary, identity of the request (correlation id), observability, transport concerns (CORS, body limits), cheap rejections (coarse rate limit), parsing, authentication, fine-grained limits and authorisation, then the handler.
- Register security controls globally with an explicit public allowlist, never per-route. A route that forgets a middleware is invisible; a route missing from an allowlist is a diff.
- Exempt webhook routes from body parsing, or capture the raw body alongside the parsed one, before anything can consume the stream (Inbound Webhooks).
- Test the ordering, not just the middleware: assert that an unauthenticated cross-origin request comes back with both a 401 and CORS headers, that an oversized body is rejected without being buffered, and that a log line for a rejected request carries a correlation id.
- When you add a middleware, place it by asking what it requires — not at the end of the file, which is where it lands by default.
What can go wrong
- A control that appears to be applied and is not, because a route above it short-circuits.
- A reordering done for readability during a refactor, changing behaviour with no test failure.
- A framework upgrade changing default middleware registration — several frameworks register a body parser or a security header set by default, and the version where that changes is a behavioural change with no code diff.
- Per-route middleware arrays that drift: eleven routes list five middlewares and the twelfth lists four.
- A middleware placed correctly for the inbound phase and wrongly for the outbound one, so it reads the request fine and cannot touch the response.
- Mounted sub-routers with their own chains, so the effective order for a request depends on which mount it landed in (Route Precedence).
- A middleware that lazily initialises shared state on first request — a limiter client, a JWKS cache — can be entered concurrently by several requests at startup and initialise several times, or hand out a half-built object (Initialization Races).
- Requests in flight during a hot reload of the chain can traverse a mix of old and new middleware. Build the new chain fully and swap it atomically.
- A rolling deploy runs two chain versions simultaneously; a client can be rate-limited by the new order on one request and not the other (Rolling Deployments).
- Ordering failures are authentication and authorisation bypasses in disguise. Anything that responds above your auth middleware is unauthenticated by construction.
- Rate limiting placed below authentication leaves the unauthenticated surface uncapped — which is the surface an attacker uses (Authenticate First, or Rate-Limit First?).
- Body-size limits below parsing turn a payload cap into a memory exhaustion vector; the limit must bound reading, not follow it.
- Signature verification below body parsing breaks the verification entirely, and the usual "fix" — re-serialising the parsed body — verifies a different byte sequence and quietly accepts forgeries if the comparison is later loosened (Webhook Signature Verification).
- Security headers and CORS applied inside the error boundary mean error responses go out without them, and error responses are exactly the ones an attacker provokes (Defence in Depth).
- "Order is a style question." It decides whether a control runs, what a rejected request costs, and whether the response the client sees is interpretable.
- "Authentication should be first because it is the most important." Importance is not the ordering criterion; dependencies and cost are. Authentication needs headers parsed and should be protected by a cheaper limit above it.
- "Rate limiting is a performance concern, so it goes near the top." It goes wherever its key is available and the work below it is worth protecting — and often in two places with two keys (Authenticate First, or Rate-Limit First?).
- "CORS is a browser thing, so it does not matter server-side." A missing CORS header on a 401 hides the real status from the client entirely, turning a clear error into an unexplained one.
- "The error handler is last, so it is innermost." In Express it is registered last and is still the outermost thing reached on the error path. Registration position and onion position are not the same statement in every framework (The Error Boundary).
Operating it
- Label rejections with the middleware that produced them.
rejected_total{stage="rate_limit"}versusstage="auth"makes an ordering regression visible as a shift between two counters. - Alert on 4xx responses that lack CORS headers if you serve browsers. It is a one-line check and it catches the most common ordering bug in web backends.
- Measure the fraction of log lines with a correlation id. Anything below ~100% points at a middleware above the id generator, or at a lost context boundary (Request Context Propagation).
- Track bytes read versus bytes accepted for upload routes; a gap means the limit is being enforced too late.
- Snapshot the assembled chain in a test and diff it in CI, so an ordering change is reviewable rather than incidental.
- Ordering decides how much work a rejected request costs, so it becomes a capacity question exactly when you are under load — the moment when the highest share of requests is being rejected.
- At 100x, moving the cheapest rejections out of the process entirely (edge rate limits, WAF, CDN) matters more than the in-process order, but the in-process order is still what protects you when the edge is bypassed or misconfigured (Rate Limiting).
- More teams means more middleware; a chain that started at four entries and reached fifteen has a dependency graph nobody has drawn.
- The per-request cost of the chain itself stays negligible; it is the *work per rejected request* that scales badly.
- Rejecting early is cheap and coarse: a pre-auth rate limit keyed on IP will occasionally throttle a legitimate shared-IP user, and there is no way to be nice about it before you know who they are.
- A single global chain guarantees coverage and forces route-specific needs — raw bodies, larger limits, streaming — into explicit exemptions.
- Ordering tests are cheap to write and they encode decisions, so a deliberate future change requires updating a test that explains why the order was chosen. That is the point and it is still friction.
- Documenting requires/provides in comments is maintenance that goes stale unless the tests hold it honest.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALThe requires/provides dependency argument holds for any pipeline — server middleware, gateway policy chains, service-mesh filters, even queue consumer decorators.
- FRAMEWORK-SPECIFICWhere a given control belongs differs. In Express you place everything by
app.use()order and the error handler goes last; in ASP.NET CoreUseExceptionHandler,UseRouting,UseAuthenticationandUseAuthorizationhave a documented required order and getting it wrong produces silently unauthenticated requests; Fastify's hook phases fix much of the ordering for you and move the decisions into hook selection. Re-derive, do not port. - SCALE-SPECIFICThe cost-ordering argument — reject cheaply before doing expensive work — is worth little on an internal service handling tens of requests per second and is a primary availability control on a public API absorbing bursts.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.