Your routing might be decided by ordering alone
first-match-wins and the no-backtracking rule in HTTP routers
A request that should work comes back as 400
You add two endpoints to a users API. One fetches a single user by ID, GET /users/:id, where id is expected to be numeric. The other lists everyone, GET /users/all. The /users/all handler is trivial — return an empty array with 200 when there is no data — and its unit tests all pass.
Then you hit /users/all from a browser and get back 400 Bad Request, with a body that says id must be numeric.
You never sent a numeric id. There is no id anywhere in that URL. So why is something complaining about one?
The culprit is neither your handler logic nor a broken validator. Your request was caught by a different route before it ever reached the one you meant. This post walks through how an HTTP framework decides which route handles a request, and the trap that decision hides.
The concrete reproduction uses Express, a middleware-style Node.js framework. But the two questions underneath it — what happens when several routes match? and what happens when the chosen one fails partway through? — apply to HTTP routers generally. Later on we will put Express next to a router that answers the first question in the opposite way, so you can see both designs at once.
Three things are covered here:
- The two main strategies a router uses to pick a route (registration-order matching and specificity-first matching)
- The no-backtracking rule: once a route has matched, a later failure does not send the request back to the next candidate
- Why the trap reappears the moment you split routes across files
The map: two families of route matching
A quick vocabulary pass first, because the whole problem lives in one word.
A route is one row in the router’s dispatch table: a pairing of “which requests” with “which function handles them”. Think of the router as a receptionist who takes each arrival and points it at the desk that should handle it.
The “which requests” part of a route is a path, and paths come in two flavors.
- A literal (static) segment — the
allin/users/all. The spelling is fixed, and it matches only on an exact hit. - A parametric (dynamic) segment — the
:idin/users/:id. It accepts any single segment and hands you its contents under the nameid. Both/users/42and/users/allmatch it.
That second flavor is where the trouble starts. The request /users/all matches /users/all and /users/:id. If both routes are registered, which one does the router pick?
Frameworks disagree on the answer, and they split into two families. Knowing which family you are in tells you what you have to defend against.
| Family | How it picks | Depends on order? | Typical implementations | The trap |
|---|---|---|---|---|
| Registration-order matching | Walks the routes in registration order; the first one that matches wins | Strongly | Middleware-style frameworks (Express and similar) |
Get the order wrong and a parametric route swallows a literal one |
| Specificity-first matching | Ignores registration order; the most specific route wins | No | Radix-tree routers (find-my-way, and Fastify, which uses it) |
Less order-sensitive, but an unexpected route wins if you don’t know the precedence rules |
A note on naming: “registration-order matching” and “specificity-first matching” are labels this article uses for convenience. They are not standardized industry terms, so keep them separate from each project’s own vocabulary (find-my-way‘s README calls its section “Match order”, for instance).
Put loosely:
- Registration-order matching tries routes top to bottom in the order you registered them and hands the request to the first match. Simple — and ordering decides everything.
- Specificity-first matching keeps a radix tree (a compressed prefix tree) internally, walks the URL down that tree, and picks the most specific match. At least for the static-versus-parametric conflict discussed here, registration order has no effect on the outcome.
An analogy: registration-order matching is “whoever raised their hand first takes the case”; specificity-first matching is “whoever knows the most about this case takes it”.
The rest of this post digs mainly into the registration-order family, because that is where ordering becomes your only line of defense — and where that line collapses in a way people rarely anticipate. The specificity-first family shows up for contrast.
Which wins: literal or parametric?
In the registration-order family, the router walks routes in registration order and hands control to the first match. Once that route sends a response or moves on to error handling, execution normally does not continue to later routes. This is first match wins: the first matching route acquires the right to handle the request. Passing control onward requires an explicit hand-off via next() or next('route') — an escape hatch covered in the next section.
The Express documentation describes route paths and parameters, and specifies that a parametric /:id captures the contents of that segment into req.params. Read the other way around: /:id matches any single segment in that position, so a literal route placed after it never gets called at all.
// Bad order: parametric first
app.get('/users/:id', getUserById); // /users/all matches here too
app.get('/users/all', getAllUsers); // normally unreachable
// Good order: literal first
app.get('/users/all', getAllUsers); // /users/all settles here
app.get('/users/:id', getUserById); // /users/42 and friends land here
Under the bad order, GET /users/all matches /users/:id on line one and arrives at getUserById with id = "all". getAllUsers is registered and never runs once. That detail — all arriving as an id — is the kindling for the 400 in the next section.
The principle worth internalizing:
In the registration-order family, register a literal route before any parametric route with the same segment count. Ordering is your only line of defense.
The specificity-first family behaves differently. find-my-way, the router Fastify uses internally, documents that static (literal) routes always take precedence over parametric ones, with wildcards ranked lowest. Its precedence runs roughly:
- Static (literal) routes
- Parametric routes with a static suffix
- Regex-constrained and multi-parametric routes
- Plain parametric routes
- Wildcard routes
Here, /users/all beats /users/:id no matter which order you register them in. You trade away the ordering worry for a different requirement: to explain why a given route won, you have to know the precedence rules above.
Once a route matches, a failure does not go back
This is the heart of the opening mystery, and the part that most often runs against intuition.
The nasty case is when two routes have the same segment count and only one of them validates its input. Reuse the exact pair from the previous section, with a single change: /users/:id now requires id to be numeric, on the premise that user IDs are numbers.
- Route Y —
/users/:id, parametric, requiresidto be numeric - Route X —
/users/all, literal, returns all users
With Y registered first, GET /users/all matches route Y and gets handed to validation as id = "all". id was supposed to be numeric and "all" showed up, so validation fails and returns 400. There is no numeric id anywhere in the URL you sent, and yet you are told id must be numeric. That is the opening mystery, fully explained.
Request: GET /users/all
Route selected: /users/:id
Params extracted: { id: "all" }
Validation: id should be numeric, got "all" → fail
Result: 400 Bad Request
At this point most people reason: “Y’s validation rejected it, so the router will try the next candidate, route X.” It will not.
Why not? In the registration-order family, once a route has matched and its middleware (validation, say) has been entered, a failure in that middleware does not make the router backtrack in the hope that some differently-spelled route will pick the request up. Implementations handle the failure in one of two ways: validation returns 400 on the spot and finishes, or it calls next(err) and transitions to error-handling middleware. In both cases, no other route is automatically reselected because of that failure, and the response is settled then and there.
This is the no-backtracking design. Read the phrase narrowly: it is not a claim about the internals of the path-matching algorithm, but specifically that once a route is chosen, a failure in its validation or handler will not cause a different route to be selected automatically. A regex engine that hits a dead end on one alternative backtracks and retries another; here, route selection and input validation inside the selected route are separate stages. A failure in the later stage does not automatically redo the earlier one.
There is an explicit escape hatch
Express does provide a deliberate way to pass the request to the next route. Calling next('route') inside a route’s callback skips the rest of that route’s processing and resumes the search at the next route. The official documentation notes that when you provide multiple callback functions, “these callbacks might invoke next('route') to bypass the remaining route callbacks”.
app.get('/users/:id', (req, res, next) => {
if (req.params.id === 'all') {
return next('route'); // give up on this route, try the next one
}
res.send(`User ${req.params.id}`);
});
app.get('/users/all', (req, res) => {
res.send('All users'); // reached only when the route above hands off
});
In practice, registering the literal route (
/users/all) first is simpler and easier to maintain than special-casing a value likeall. Treat the snippet above as a demonstration of hownext('route')behaves, not as a recommended design.
The distinction to hold onto: failing with an error and handing off with next('route') are different things. The former goes straight to error handling and stops; only the latter reaches the next candidate. So the automatic fallback people imagine — “if validation rejects it, another route will catch it” — does not exist. If you want a fallback, you have to write one.
Splitting routes across files breaks the ordering rule
The rule so far is “register literals before parametrics”. In a small app you satisfy it by listing routes top to bottom in one file.
As the app grows, though, you split routes into per-feature files (sub-routers). Here is the blind spot: you can obey the ordering rule inside each file and still lose, because a literal route moved into its own file has left that ordering entirely.
// usersAllRouter.js … the more specific, literal-ish route
router.get('/users/all', getAllUsers);
// usersByIdRouter.js … the parametric route
router.get('/users/:id', getUserById);
// app.js … composing the sub-routers. This order is the new line of defense.
app.use(usersByIdRouter); // mounted first, so /users/:id swallows all
app.use(usersAllRouter);
Each file looks well-ordered on its own, but the composition site — the order of app.use calls — is now the “only line of defense”. Mount the parametric side first and the shadowing from the previous section returns unchanged.
The fix depends on your family:
- In the registration-order family, manage the composition order. Mount literal-ish sub-routers before parametric sub-routers with the same segment count.
- In the specificity-first family, the problem is unlikely to arise, since ordering does not decide the outcome. Do still learn the precedence rules.
Put the test where the bug is: at the seam
This trap carries a significant implication for test design.
Unit tests that mount a sub-router on its own cannot detect this bug, no matter how many you write. Shadowing only comes into existence at the moment several sub-routers are composed into the same app. Mount one router alone and it behaves correctly, so the test is green — correctly and uselessly.
When a bug lives not inside a unit but at the seam between units, put the test at the seam.
Concretely, write an integration test that mounts the sub-routers in the real mount order. Watch out for one thing: if you assert only on the status code, a test can pass because some other route happened to return 200. Assert which handler ran — check the response body, or spy on the handler and verify it was called — and the test gets much harder to fool.
The assertion that matters most for day-to-day regression testing is that with the production mount configuration, GET /users/all actually reaches getAllUsers. Beyond that, if your goal is to document and explain the cause, testing a deliberately broken order makes the test itself state that ordering is load-bearing. Treat that second one as explanatory rather than a required regression test.
- Production mount order →
GET /users/allreturns200plus the full user list - Deliberately broken order → shadowing yields
400(swallowed by/users/:id, then rejected becauseidis not numeric)
The first is what turns red the day someone reorders the mounts by accident.
Decision guide and takeaways
A short checklist for when you are unsure during implementation.
First, work out which family your router belongs to
- If behavior changes when you reorder route registrations, you are in registration-order matching. Ordering is your defense.
- If the specific route wins regardless of registration order, you are in specificity-first matching. Go read the precedence rules.
In the registration-order family
- With equal segment counts, register literal routes before parametric routes.
- When you split routes into files, manage the composition (mount) order by the same rule. Ordering within a file is not enough.
- There is no automatic fallback where a rejected validation lets another route pick the request up. To hand off, use an explicit exit such as
next('route').
In your tests
- Route collisions surface only at the seam. Write an integration test using the real mount order.
- Assert the correct order and the broken one, so that “ordering is load-bearing” is pinned down.
Routing looks like the part the framework quietly handles for you. In reality, design choices — match strategy, ordering, and whether backtracking happens — decide its behavior. The opening 400, complaining that an id you never sent is not numeric, stays a mystery until you know those choices. Once you know your router is “first-match-wins, and it never goes back”, the cause is a straight line.
Primary sources
- Express official guide, “Routing” (route path specification, the
req.paramsparameter object, andnext('route')skipping the remaining callbacks of the current route to pass control to the next route) - Express official guide, “Error handling” (
next(err)skips the remaining regular routes and middleware and transitions to error-handling middleware; it does not fall back to regular routes) find-my-wayrepository (radix-tree router precedence: static > parametric with static suffix > regex/multi-parametric > plain parametric > wildcard, resolved independently of registration order)- Fastify official documentation, “Routes” (routing built on
find-my-way)








