Route Precedence
When two routes can match one path, something decides which wins — and in half of all frameworks that something is the order of lines in a file.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
Two routes could match the same request. Which one runs, and did you choose that or inherit it?
/users/{id} returns a user. /users/me returns the caller. Both are reasonable, and both match GET /users/me.
Define the routes in the order that reads well, grouped by resource. The framework will work it out — routes are distinct enough that ambiguity is rare.
/users/:id is registered above /users/me, so GET /users/me enters the by-id handler with id = "me". Best case a 400; worst case a database error and a 500; unluckiest case an integer cast that silently returns user 0.
/users/:idis registered above/users/me, soGET /users/meenters the by-id handler withid = "me". Best case a 400; worst case a database error and a 500; unluckiest case an integer cast that silently returns user 0.- A SPA fallback or static-file catch-all mounted before the API returns
index.htmlwith status 200 for every unmatched API path, so client error handling sees success and tries to parse HTML as JSON. - A new route added at the bottom of a file never runs, because a broader pattern above it already matches. Nothing errors; the code is simply dead, and it looks live.
- Trailing-slash handling redirects
/orders/to/orderswith a 301, and a browser turns the redirected POST into a GET, so a form submission silently becomes a page load. - Two teams register the same path in different modules; one wins by import order, and which one wins changes when someone reorders imports for lint reasons.
What is actually happening
- Precedence is a total order over candidate routes. There are two common ways to build it: registration order (first match wins) and specificity ranking (most specific pattern wins, regardless of when it was registered).
- Specificity, where it exists, is a documented comparison over segments: a literal segment beats a parameter, a parameter beats a wildcard, a longer pattern beats a shorter one, and constraints usually break ties. The details differ per framework and are worth reading once for the one you use.
- Frameworks that rank by specificity can detect that two patterns are mutually unrankable and refuse to start. Go's
http.ServeMuxpanics on such a registration; Fastify throws when the same method and path are registered twice. This converts a runtime mystery into a startup failure, which is a large improvement. - Frameworks that use registration order cannot detect anything, because shadowing is a legitimate technique there.
/users/meabove/users/:idis not a conflict — it is the mechanism. - Method is part of the key, so
GET /orders/:idandPOST /orders/:iddo not compete. Path-only shadowing across methods still bites when a catch-all is registered without a method filter. - Mounting adds a second level: a sub-router mounted at
/api/v1matches the prefix first, and precedence inside it is evaluated independently of routes outside it. A specific route outside a mount cannot win against a broad route inside it if the mount matched first.
The `/users/me` problem
http.ServeMux. Behaviour on other stacks — and on earlier versions of these — differs; confirm against the version you deploy.This is the canonical example because it is so reasonable. Both routes are correct, both are idiomatic, and on a first-match router the wrong one wins in the order most people write.
What makes it instructive is the failure shape. It is not an exception at registration and not a 404. It is the by-id handler running with the literal string me, which then produces whatever your parameter handling does with a non-id (Path Parameters) — a 400, a 500, or a successful response for the wrong record.
1// Express — first registered match wins2app.get('/users/:id', getUser) // registered first3app.get('/users/me', getMe) // dead code: never reached4// GET /users/me -> getUser with req.params.id === 'me'5 6// Fix: literal before parametric7app.get('/users/me', getMe)8app.get('/users/:id', getUser)9 10 11// Fastify — radix trie, static segment beats parametric12fastify.get('/users/:id', getUser)13fastify.get('/users/me', getMe) // order irrelevant14// GET /users/me -> getMe15 16// Go 1.22+ — most specific pattern wins; ambiguity panics17mux.HandleFunc("GET /users/{id}", getUser)18mux.HandleFunc("GET /users/me", getMe) // order irrelevant19// mux.HandleFunc("GET /a/{x}/c", h1); mux.HandleFunc("GET /a/b/{y}", h2)20// -> panic at registration: neither is more specific than the otherThe Express fix is a line order. The Fastify and Go versions have no fix because there is no bug. That difference is the whole lesson: identical route definitions, different behaviour, and only one of the three tells you.
Order literal, parameter, wildcard — and prove it
The discipline that survives framework changes is to write the table as if order mattered, and then test that resolution matches your intent. On an order-dependent router the ordering is the fix; on a specificity router it is documentation that costs nothing.
The test is the part people skip. It is not a unit test of a handler — it is an assertion about the assembled application: given this method and this path, which handler runs. Ten lines, and it is the only artefact that catches a shadowed route before a user does.
// routes are grouped by resource and read nicely
app.use(express.static('public')) // catch-all, registered first
app.get('/orders/:id', getOrder)
app.get('/orders/summary', summary) // shadowed, silently dead
app.use(apiRouter) // never reached for /orders/*const CASES = [
['GET', '/orders/summary', 'summary'],
['GET', '/orders/4711', 'getOrder'],
['GET', '/api/missing', '404'], // not the SPA fallback
['POST', '/orders/4711', '405'],
] as const
for (const [method, path, expected] of CASES) {
it(`${method} ${path} -> ${expected}`, async () => {
const res = await request(app)[method.toLowerCase()](path)
expect(res.headers['x-matched-route']).toBe(expected)
})
}Reading gives you an opinion about resolution; the table gives you a fact, and it keeps being a fact after someone reorders imports, adds a mount, or upgrades the router. The /api/missing case is the one that matters most — it asserts that the SPA fallback does not swallow API 404s.
Precedence failures you will actually meet
Each row below has been shipped by competent teams. The common thread is that none of them produce an error at the point of the mistake — the symptom appears somewhere else, usually in a client.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
/users/:id registered above /users/me | 400, 500, or the wrong user returned | First-match router; the parametric route shadows the literal one | Move literal routes above parametric ones and assert it with a resolution test |
| SPA fallback mounted before the API router | Client receives HTML with status 200 and fails at JSON.parse | Catch-all matched every unmatched path including /api/* | Mount the fallback last and exclude API prefixes so they return a real 404 |
| Trailing-slash redirect with 301 | POST body disappears; the request arrives as a GET | Clients historically downgrade the method on 301/302 | Use 307/308, or normalise the path before routing rather than redirecting |
| Route added in a new module, imported later | New endpoint 404s in production, works in its own test | A broader pattern registered earlier already matched | Diff the startup route table in CI; fail the build on an unreachable route |
| Public route registered above an authenticated one | Data served without authentication | Shadowing bypassed the route that carried the auth middleware | Apply authentication globally with an explicit public allowlist, not per-route (Middleware Ordering Is a Correctness Decision) |
| Gateway path rule and application router disagree | A path the gateway denies is served by the app | Different normalisation or precedence rules at two hops | Enforce authorisation in the hop that dispatches (Where the Check Belongs) |
How to build it
Most important first.
- Order literal-first, parameter-second, wildcard-last within every group, even on a framework that ranks by specificity. It costs nothing and makes the file readable as if it were the precedence rule.
- Mount catch-alls last and scope them: an SPA fallback should be registered after the API and should refuse to serve HTML for paths under
/api, returning a real 404 instead. - Write a route-resolution test: a table of representative paths and the handler each must reach. It is a handful of lines and it is the only test that catches shadowing, which no unit test will.
- Prefer a distinct path over a special value where you can —
/users/meis fine, but ifmecollides with a real id space,/meat the top level has no ambiguity at all (Resource or Action?). - Choose trailing-slash policy explicitly, and if you redirect, use 307/308 so the method survives (Status Codes From the Server's Side).
- Print the resolved route table at startup in development, or expose it on an internal endpoint. Shadowing is obvious the moment you can see the ordering.
What can go wrong
- Dead routes: registered, tested in isolation, never reachable in the assembled app.
- A precedence change caused by an unrelated refactor — extracting routes into a module and importing it in a different place.
- A framework upgrade that changes matching rules. Go's mux gained wildcards and specificity in 1.22; a service relying on the old longest-prefix behaviour resolves differently after the upgrade.
- Case-sensitivity mismatch between a CDN that lowercases paths and a case-sensitive router, so a URL works from one client and not another.
- A gateway that routes
/api/*to a service that itself mounts everything under/api, giving/api/api/...internally and a 404 nobody can locate (API Gateway).
- Routes registered from asynchronous plugin initialisation can land after the listener is accepting connections, so precedence differs for the first few requests after a restart. Build the whole table before binding the socket.
- Two deployments serving different route tables during a rolling update mean a client can hit the old precedence on one request and the new on the next — which is only safe if both tables are individually correct (Rolling Deployments).
- Shadowing an authenticated route with an unauthenticated one is a complete authorisation bypass, and it looks like a routing tidy-up in the diff. A broad public route registered above a protected one wins on a first-match router.
- Catch-all routes reached before authentication middleware, when middleware is registered per-route rather than globally, serve whatever the catch-all serves to anyone (Middleware Ordering Is a Correctness Decision).
- A debug or health route matching more broadly than intended —
/health*also matching/healthz-internal— exposes internal state (Health Checks: Startup, Readiness, Liveness). - Precedence differences between a proxy's rules and the application's router let a request be authorised as one route and executed as another. The mitigation is to make the enforcing hop and the dispatching hop the same one (Where the Check Belongs).
- "Specificity ranking means order does not matter." It means registration order does not matter. Mount order, prefix stripping and middleware attachment still do.
- "The 404 means the route is missing." Frequently the route exists and a broader pattern above it answered first — or the method did not match and the framework reported 404 rather than 405.
- "We have tests, so shadowing would be caught." Unit tests call handlers directly. Only a test that goes through the assembled router can see precedence.
- "Register catch-alls first so nothing 404s." That is exactly the bug: everything matches, nothing else runs, and every response is a 200.
- "It is only a trailing slash." A redirect can change the method and drop the body, which turns an idempotent-looking fix into a data-loss report (Idempotency in Backends).
Operating it
- Log the matched route template on every request. A route with zero traffic since deploy is either unused or shadowed, and you cannot tell those apart without the label.
- A startup log or artefact of the full ordered route table, diffed in CI. A precedence change then shows up in a pull request rather than in an incident.
- Counters for the catch-all handler specifically. A fallback route serving meaningful traffic is a routing bug reported as a metric.
- Watch 404 rate by path prefix after every deploy; a shadowing regression usually appears as a sharp change in one prefix ("What Changed?" — Deploy Markers and the Invisible Deploys).
- Precedence problems scale with route count and team count, not with traffic. One service, one file, thirty routes: rare. Twelve teams registering into one gateway: routine.
- At high route counts, first-match evaluation cost grows linearly while trie lookup stays effectively flat — but the correctness problem grows much faster than the performance one.
- Under a gateway, the precedence rules compose: yours, the gateway's, and possibly a service mesh's. Each hop has to be reasoned about separately.
- Specificity ranking removes order-dependence and removes deliberate shadowing, which is genuinely useful for migrations — routing a subset of traffic to a new handler by registering a narrower route above the old one.
- Startup-time conflict detection turns a silent bug into a crash loop. That is the right trade for correctness and a bad surprise during a deploy if nobody has seen it before.
- Route-resolution tests are cheap to write and become a second place to update when paths change.
- Avoiding special path values entirely (
/me,/latest,/default) removes a class of collisions and costs API expressiveness that clients often like.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- FRAMEWORK-SPECIFICThis is the most framework-dependent lesson in the module. Express, Django and Rails: first registered match wins, shadowing is legal and silent. Fastify and Go 1.22+
http.ServeMux: specificity wins and unrankable pairs are rejected at registration. Flask/Werkzeug: sorted by rule complexity at build time, so neither decorator order nor naive specificity intuition predicts it. ASP.NET Core: documented precedence with an ambiguous-match exception at request time. - GENERALThe underlying rule — if two patterns can match one path, something must break the tie, and you should know what — holds everywhere including gateways, service meshes and CDN path rules.
- SCALE-SPECIFICBelow roughly one team and a few dozen routes, precedence is a curiosity you meet once. Above one gateway shared by several teams, it is a standing coordination problem that needs a conflict check in CI.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.