express 5 — router 2.2.0 — a working engine, not a description of one

Router, Layer, Route

A Router is an ordered array of Layers plus a walk over it.

A Layer is one executable step: a compiled matcher and a function.

A Route is a method-aware collection of handlers for one already-matched path.

Registration builds that structure. Dispatch walks it. Nothing else happens.

Colour carries the encoding: Router, Layer, Route, the handler you wrote, a trap, and measured numbers. It holds in the prose, the diagrams, the code panes and the annotations alike.

Every figure on this page is driven by a reimplementation of router that runs in the page, and every reimplementation of a mechanism is shown beside the real source it was built from. The engine is checked against real Express over a live server on 31 request paths, so the traces are not staged.

reading order
one request, end to end

Three objects, three questions

The three types are easy to confuse because all three hold a path and all three hold a stack of something. They are not easy to confuse once each one is reduced to the question it answers.

The consequence of splitting it this way is the thing worth holding on to: a route handler sits 2 Layers deep. One Layer to match the path, a Route to hold the method map, and a second Layer inside that Route to carry the handler itself.

what a registration actually builds

Reading the structure top-down makes the two stacks explicit. router.stack holds Layers. A Layer registered by a verb method owns a Route, reachable through layer.route. That Route holds its own stack, and the entries in that stack are Layers too — one per handler, each tagged with a method.

the pipeline reading

It is tempting to describe the stack as a linked pipeline, with each Layer holding a reference to the next one and next() calling through the chain. Nothing in the implementation is linked. There is no layer.next, and a Layer has no idea that any other Layer exists.

What is actually there: an array, an integer index into it held in a closure, and a function that advances that index. It behaves like a pipeline during dispatch, which is a statement about the traversal, not about the data structure.
who owns which decision

Most of the confusion in this area comes from attributing an action to the object it concerns rather than the object that performs it. Method selection concerns a route, but it is performed by a Route only for handlers inside it — the Router performs its own, coarser method check first, to decide whether entering the Route is worth it at all.

place the action with its owner

The shapes, and the calls that build them

Three constructors and about twenty methods. Written out as declarations, the whole routing API fits on one screen — and the field lists are where the differences between the three objects stop being a matter of description.

Two things are worth reading off the declarations rather than the prose. The first is how little state there is: a Router is one array plus three options, a Route is two fields and a set, and a Layer is a compiled matcher, a function, and three slots that get overwritten per request. The second is that a Layer's declaration does not change between its three jobs. The same class serves middleware, route wrapper and method handler, and which job it is doing is legible only from which fields are set.

the declared shape against a live instance

The method list matters as much as the fields, because the return types are what make the two registration idioms behave differently. Everything that registers hands back the object you called it on — except route(), which hands back the Route. That single exception is why app.route(p).get(a).post(b) builds one Route and app.get(p, a) followed by app.post(p, b) builds two.

every call, and what it hands back

one signature that is not a registration

app.get is overloaded on an Express app. With a path and handlers it registers a route; with a single argument it is the settings getter and returns the value of that setting. app.get('etag') reads a setting, and no Layer is appended.

Layer

A compiled matcher and a function. Everything else on a Layer is either an option fixed when it was registered, or a scratch field overwritten on every request that reaches it.

The matcher is built once, in the constructor, from the path string and three options, by path-to-regexp. Per request the only work is running it. This is the split that makes the cost model comprehensible: patterns are compiled at boot, and a request pays only for exec calls.

Two of those three options come from the router and are read only here: strict routing, which decides whether a trailing slash is forgiven, and case sensitivity, which becomes a flag on the compiled expression. Changing either after a route is registered has no effect, because the Layer that route produced was compiled already.

the same Layer at three moments

A successful match writes three of those scratch fields: layer.path holds the portion of the pathname that was consumed, layer.params holds the captures, and layer.keys holds their names. A failed match clears the first two back to undefined, so a stale value from an earlier request cannot be read by accident.

Two fields decide what kind of step a Layer is, and neither of them is a type tag. layer.route holds a Route when the Layer came from a verb method, and is explicitly set to undefined when it came from use(). layer.method is set only on the Layers inside a Route. Between them they select from three behaviours without a single subclass.

The end anchor is the whole difference

The same path string produces two different matchers depending on which registration call built the Layer. use() compiles with end: false, so the pattern matches a prefix and stops at a path separator. A verb method compiles with end: true, so the pattern has to consume the entire pathname.

compile a pattern, test a pathname

Matching against a pathname, rather than a URL, is deliberate: the query string is parsed off before any matcher runs, which is why /users?sort=desc reaches a route registered as /users. Param values are decoded during the match, and that is the reason the traversal wraps the call — a malformed percent-escape throws a URIError carrying status 400 rather than failing to match.

Two entry points, gated on arity

A Layer is not called directly. The traversal calls one of two methods on it, chosen by whether an error is pending, and each of them refuses to run a function whose declared parameter count is wrong for the mode. That refusal is not a validation error — it silently calls next and moves on.

which combinations actually run

the arity trap

The count comes from Function.length, which stops at the first parameter with a default value and ignores rest parameters. An error handler written as (err, req, res, next = noop) reports a length of three, registers as ordinary middleware, and is skipped in exactly the situation it exists for.

Layer, part by part

Route

The smallest object of the three: a path it does not match, a stack of method-tagged Layers, and a map of the verbs it answers.

A Route exists because the path question and the method question have different shapes. The path question is expensive and regular-expression-shaped, so it is asked once by the enclosing Layer. The method question is a string comparison, so it is asked repeatedly and cheaply inside the Route.

The handlers themselves live in Route.stack, one Layer per registered function, in the order the verbs were chained. That division explains the methods map. Before entering a Route at all, the Router asks it whether it handles the request's verb. A no makes the Router skip the Layer and keep walking, which is why a path that matches with the wrong verb usually ends as a 404 rather than a 405 — the walk simply continues past it and finds nothing else.

the inner walk, with its own cursor

Inside dispatch the selection rule is one line: a Layer matches if it has no method, or if its method equals the request's. Nothing here touches a path. The inner Layers were built with the path '/' and their matchers are never called, which makes them the one genuinely vestigial thing in this design.

Route, part by part

Router

The Router owns traversal. Layers own individual executable steps. Routes group method-specific steps. That is the whole invariant.

A Router is built as a function and then given Router.prototype, which is what lets one router be mounted inside another as though it were middleware. Its state is one array. Its behaviour is one function, handle(), entered once per request.

Everything that makes a router feel like a pipeline lives in that one activation: a cursor, a pending error, the prefix it removed, and a memo of which param callbacks have run. None of it is stored on the router, so two concurrent requests through the same router share nothing but the Layer objects themselves.

who holds the cursor

Set against an ordinary loop, the difference is where the position lives. A for..of consumer holds its own cursor and asks for the next value when it wants one. In a router, the walk holds the cursor and the handler is handed a function that resumes it. That inversion is the entire reason middleware can be asynchronous: the walk is not on the stack waiting, it has returned, and next restarts it whenever it is called.

“next() calls the next middleware”

Calling next() from a handler does not invoke another handler directly. It re-enters one specific next closure, which undoes that router's URL rewrite, reads its argument for sentinels and errors, then searches forward from the cursor for the next Layer that matches.

The observable difference: one next() call may test a dozen Layers and run none of them, and a handler that calls next() twice advances the same walk twice.

The search loop

Between one Layer running and the next, the walk may reject any number of candidates, and the reasons are not interchangeable. A failed match is one thing; a match that threw is another, and leaves an error pending — the wrapper the search calls, matchLayer(), exists precisely so that a throwing matcher becomes a value rather than an exception. A Route rejected on method is a third, and a Route skipped because an error is already pending is a fourth.

The cursor advances inside that loop, in the same expression that reads the Layer: layer = stack[idx++]. By the time a Layer runs, the cursor already points past it, which is precisely what makes a re-entrant next() resume after the current step without any bookkeeping of its own.

What a router puts back

A router overwrites three properties on the request — baseUrl, params and next — and wraps its exit callback in restore(), a five-line helper that puts all three back. That helper is the reason a mounted router is transparent: by the time the parent's walk resumes, the request looks the way the parent left it. Anything else a handler attached, req.user and its kind, is untouched, which is what makes attaching state in middleware work at all.

Router, part by part

Four ways a handler finishes

A handler has exactly four ways to hand control back, and they are distinguished only by what is passed to next. Two of them are strings that the traversal reads as control flow rather than as errors.

the same router, four different calls

A fifth option is to answer and call nothing, which ends the traversal by simply never resuming it. The walk is not waiting on the stack, so there is nothing to unwind and nothing to notice: the cursor stays where it is for the lifetime of the request and the handle() activation is collected once the response is done with it.

on double calls

Answering and then calling next() resumes the walk with a response already sent. Later Layers run normally, and the first one that tries to write produces a headers-already-sent error — reported at the Layer that wrote second, which is rarely the Layer that has the bug.

Composition

Every composition idiom is a statement about the shape of the stack. Reading the shape a call produces is more useful than remembering what the call is for.

six idioms, and the structures they build

Mounting

Passing a router to use(path, router) appends one Layer whose handle is that router. Nothing is copied, flattened or registered twice. A child with 40 routes costs the parent 1 matcher test, and if that test fails the whole subtree is skipped — which is the single structural optimisation the router offers.

Because the mount Layer is middleware, the matched prefix is moved out of req.url and into req.baseUrl before the child runs. That is the whole mechanism behind a router not knowing where it is mounted. It is also the mechanism behind three URL fields that disagree.

the three URL fields, level by level

req.params behaves differently from the URL fields: it is replaced per matching Layer rather than accumulated. A capture made by a mount path belongs to a Layer in the parent router, so a child cannot see it — unless the child was constructed with mergeParams, which merges the parent's object underneath its own.

whose option is it

mergeParams is set where the child router is constructed, not where it is mounted. Setting it on the parent has no effect on what the child sees, because the assignment that consults it is performed by the router doing the matching.

Factories against shared instances

A module that exports a built router exports a single stack. Mounting that instance twice mounts one object twice: the two mount points share its Layers, its param callbacks and its options, and its Layers' scratch fields are written by requests arriving through either path. A router factory — a function that constructs and returns a router — avoids all of that by construction, and takes its dependencies as arguments rather than imports.

Where an error handler can go

An error handler is reachable only if a cursor can still get to it, and cursors only move forward. Registered before the thing that fails, it is already behind the cursor when the failure happens. Registered inside a child router, it catches what that child produces and nothing else, because a sibling of the mount is walked by a different cursor entirely.

five placements, five real runs

Param callbacks

A param callback is keyed on a placeholder name rather than a path, and processParams() runs it before any Layer in that router whose match captured the name. It is memoised per name and value for the life of the request, so two Layers capturing the same :id trigger one load.

before the handler, once per value

Registration, call by call

Composition strategies

Ordering as a cost model

A request pays for every matcher ahead of the Layer that answers it. There is no index, no trie, no specificity ranking and no sorting step anywhere in the router. The order routes were registered in is the order they are tested in, and that is the entire performance story.

matcher invocations for one request

Two readings follow from the counts. Global middleware added with a pathless use() is free to test, because that Layer takes the slash fast path, which returns before any regular expression runs — so a long list of global middleware is a cost in handler work, not in matching. And grouping routes under mounts converts a linear scan into a shallow tree, since a failed mount test skips everything beneath it.

Where this engine differs from router 2.2.0

The engine behind these figures is a reimplementation, and a reimplementation that quietly disagreed with the real thing would make every trace on this page a fiction. It is checked against real Express 5 over a live HTTP server: 16 scenarios, 31 request paths, comparing the handler invocation sequence with a snapshot of req.url, req.baseUrl, req.originalUrl, req.params and req.route at every call, plus the final status and body.

What it deliberately does not implement:

omittedin the real implementationconsequence for these figures
synchronous hop guardAfter 100 synchronous next() calls, the walk defers to the next turn of the event loop.None for ordering. It would change stack depth, which no figure shows.
automatic OPTIONSCollects verbs from path-matching Routes and answers with an Allow header.OPTIONS requests are not traced. The mechanism is described but not executed.
promise-returning handlersA returned promise is watched, and a rejection becomes a pending error.Every handler in the scenarios is synchronous.
protocol-relative URLsA protohost offset keeps //host/path intact while its path is trimmed.No scenario uses an absolute request target.
numeric param offsettingmergeParams offsets positional captures so both levels survive.Our merge is a plain object merge, so named captures are exact and positional ones are not modelled.
array paths, RegExp pathsA Layer accepts an array of patterns, or a RegExp with named groups.The compiler handles literal segments, :name and *splat only.
deferred exitExhaustion and the 'router' sentinel defer the exit callback with setImmediate.Ours exits synchronously. Invisible in a synchronous scenario, which is all of them.

Two further notes on fidelity. The real Layer.handleRequest guards fn.length > 3 and handleError guards fn.length !== 4; ours does the same, which is why the arity figure reports measured behaviour rather than a claim. And the search loop's matched-middleware branch uses continue where ours uses break — identical in effect, because the loop condition is already false once a match is found.

Back to JIT-Atlas