What happens under app.get

Express routing is three objects and one loop that is not a loop. Everything else — mounting, error handling, req.params, the 404 you did not write — falls out of how those three fit together.

Express 5 semantics every figure runs a real routing engine click any next to expand it

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 whole object model, as a type type Layer = { regexp: RegExp, keys: string[], handle: Function | Route }

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.

// two Layers in router.stack, two separate Route objects app.get('/users/:id', getUser) app.post('/users/:id', updateUser) // one Layer, one Route holding two method entries app.route('/users/:id') .get(getUser) .post(updateUser)

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.

The compiler is live. Pick any pattern and any request path — the tokens, the regexp source, the keys and the params are all produced by the same lexer the rest of this page uses.
consequence

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.

Frames on the right are recorded by a real router run, not staged. The final step runs a second router whose 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.

app.use((req, res, next) => { const started = Date.now() next() // runs after the response was sent, still inside this frame log.info(Date.now() - started) })

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.

A request is mid-flight inside the Route, in 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.

Every length in the table is read from a real function object when the page loads.
the quiet failure

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.

app.get('/x', async (req, res) => { throw new Error('caught in v5, dropped in v4') }) app.get('/y', (req, res) => { setTimeout(() => { throw new Error('escapes both') }, 0) })

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.

Counted, not timed: each bar is the number of regexp.exec calls the engine made for that request.

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.

Back to JIT-Atlas