Three objects, one invariant
A Router owns an array of Layers. A Route owns an array of Layers too. The Layer is the only record that appears in both, and it carries one field that decides everything downstream.
layer.handle holds either a plain function or a Route. Never both, never something else. When it holds a function, the Layer is middleware. When it holds a Route, the Layer is a route entry and a second dispatch level opens beneath it.
The array itself — router.stack — is plain and ordered, which means registration order is the routing algorithm. That single union is why the same walking code serves middleware and routes. The Router does not branch on “is this a route” when deciding whether to enter a Layer — it matches a path and calls handle. The branch happens one level down.
Two calls, two Routes
Registering the same path twice does not merge anything. Each app.get or app.post call runs router.route(path), which constructs a new Route and pushes a new Layer. Only explicit chaining shares one.
The difference is not cosmetic. In the first form a POST tests the path regexp twice — once for the GET Layer, which matches on path but is skipped on method, and once for the POST Layer. In the second form the path is tested once and the verb is selected from a map. That is the mechanism behind the numbers in the last section.
Layer — a compiled matcher
By the time a Layer exists the path string has already been consumed. path-to-regexp ran once, at registration, and produced a RegExp plus an ordered keys array. layer.match is then a single exec call, and req.params is those capture groups zipped onto the keys.
Because :param compiles to ([^/]+?) and decoding happens after matching, a request for /users/a%2Fb matches a pattern that explicitly excludes slashes, and req.params.id arrives holding 'a/b'. The pattern constrains the raw URL, not the decoded value.
The path string does survive on the Layer, but only as debugging residue. Nothing reads it during dispatch, which is why a pattern that compiles to something unintended fails quietly rather than throwing.
Router — next is the loop
Router.prototype.handle declares a local idx and defines next as a closure over it. Calling next() advances the cursor, finds the next Layer whose regexp matches, and invokes it — passing itself as the third argument.
So there is no iteration construct. The handler you write is holding the loop’s continuation, and the walk advances only because your code chose to advance it.
logger omits next() — the frame count is what that engine actually produced. Watch the depth column. Each next() is called from inside the previous handler, so frames accumulate rather than unwind. Express does not trampoline the walk. A request passing through twenty synchronous middlewares is genuinely twenty-odd frames deep when the last one runs, and everything written after next() in an earlier handler executes on the way back out — after the response has already been sent.
That timing works for a fully synchronous stack and breaks the moment anything downstream awaits, because next() returns as soon as the first suspension point is hit. The reliable version listens for the response’s finish event instead.
Route — the second cursor
route.dispatch repeats the closure pattern over route.stack, comparing each Layer’s method against req.method. Two nested cursors now exist, which makes “move on” ambiguous — hence the sentinels.
auth. Each sentinel abandons a different amount of the remaining walk. next('route') and next('router') are checked by identity before the argument is treated as an error. That check is why passing a caught error string through next can silently behave as a sentinel rather than an error — and why next('router') forwarded as if it were an error turns a deliberate decline into a 500.
Anything else truthy puts the walk into error mode, and a request entering a mounted sub-router additionally has its req.baseUrl rewritten for the duration of that router’s walk.
The method check also happens earlier than most people expect. A Route with no entry for the request verb is skipped by the outer walk, as though its path never matched at all. The walk therefore never learns that the path was recognised, which is exactly why an unmatched verb produces a default 404 rather than a 405 with an Allow header.
Middleware — the arity gate
There is no app.useError(). Express decides whether a function is an error handler by reading fn.length. handle_request runs a Layer only when the arity is below four; handle_error runs it only when the arity is exactly four.
An error handler written as (err, req, res, next = noop) reports a length of three. It registers as ordinary middleware, never runs in error mode, and never catches anything — while looking correct at every call site.
What still escapes
handle_request wraps your handler in try/catch, so synchronous throws have always been routed to next(err). Express 5 additionally inspects the returned thenable and routes a rejection the same way; Express 4 discarded it, which is why async handlers hung there without a manual .catch(next).
What escapes in both versions is a throw from inside a scheduled callback. By the time a setTimeout fires, the try block has already returned and there is no frame left to catch into — so it reaches uncaughtException, not your error handler.
Cost is position, not lookup
Every Layer holds its own compiled regexp and they are tested in registration order. There is no index, no trie, nothing to consult — so the cost of routing a request is its position in the stack, and a request that matches nothing pays for the whole thing.
This is the mechanical argument for mounting sub-routers rather than registering flat. A miss under /api costs one failed prefix test at the mount Layer instead of one test per route behind it. The deep hit improves too, but not to one — the sub-router still walks its own stack.
It is also the argument for putting app.use(express.static(...)) and other broad middleware where you actually want them. A middleware registered first runs its match on every single request, including the ones it will never handle.