HIROSE PAPER MFG. CO., LTD.

Employees' Blog

Your routing might be decided by ordering alone
first-match-wins and the no-backtracking rule in HTTP routers

Published on: 2026.08.06 Last updated: 2026.08.06
Illustration captioned 'Order decides everything' with a 400 marking, showing a letter being intercepted by the wrong window instead of the one it was addressed to

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.

Illustration with a 'Reception' sign and a 'dispatch' label, showing a receptionist directing an arriving delivery to one of several desks behind them
Figure 1: Routing is the receptionist who directs each visitor to the right desk

The “which requests” part of a route is a path, and paths come in two flavors.

  • A literal (static) segment — the all in /users/all. The spelling is fixed, and it matches only on an exact hit.
  • A parametric (dynamic) segment — the :id in /users/:id. It accepts any single segment and hands you its contents under the name id. Both /users/42 and /users/all match 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?

Illustration showing one delivery branching to two slots, one labelled /users/all 'fixed spelling' and the other /users/:id 'any value', with the words 'matches both'
Figure 2: One URL can match more than one route

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.

FamilyHow it picksDepends on order?Typical implementationsThe 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”.

Illustration contrasting two headings, 'First hand up' and 'Knows it best', as the deciding factor for who takes a case
Figure 3: “Whoever raised their hand first” versus “whoever knows the most”

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:

  1. Static (literal) routes
  2. Parametric routes with a static suffix
  3. Regex-constrained and multi-parametric routes
  4. Plain parametric routes
  5. 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, requires id to 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.

Illustration showing a delivery passing a one-way door into the /users/:id window, rejected with 'check fails' and sent to the 400 exit, while the path back to the /users/all window is blocked with 'no way back'
Figure 4: Once you are let through, there is no way back

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 like all. Treat the snippet above as a demonstration of how next('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.

Illustration of two sorting lanes each marked 'Fine on its own', misaligned only at the joint marked 'Breaks at the seam'
Figure 5: Correct on its own, broken only once combined

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/all returns 200 plus the full user list
  • Deliberately broken order → shadowing yields 400 (swallowed by /users/:id, then rejected because id is 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

  1. With equal segment counts, register literal routes before parametric routes.
  2. When you split routes into files, manage the composition (mount) order by the same rule. Ordering within a file is not enough.
  3. 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

  1. Route collisions surface only at the seam. Write an integration test using the real mount order.
  2. 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

Pixcel Art of Aki. holdin a cat.

About the Author

Aki Matsumura

Joined HIROSE PAPER MFG. CO., LTD. in November 2024.

Brings a diverse professional background spanning retail, welfare services, and food service before transitioning into system development.

Currently serves as an in-house systems engineer, responsible for internal database development and system improvement initiatives across the company.

View posts by this author