RoutingFRAMEWORK-SPECIFICGENERALSCALE-SPECIFIC

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.

What actually happensHow to build it

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.

The question

Two routes could match the same request. Which one runs, and did you choose that or inherit it?

The requirement

/users/{id} returns a user. /users/me returns the caller. Both are reasonable, and both match GET /users/me.

The obvious build

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.

Why it breaks

/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.

How it breaks in production
  • /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.
  • A SPA fallback or static-file catch-all mounted before the API returns index.html with 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 /orders with 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.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

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.ServeMux panics 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/me above /users/:id is not a conflict — it is the mechanism.
  • Method is part of the key, so GET /orders/:id and POST /orders/:id do 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/v1 matches 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

FRAMEWORK-SPECIFICRegistration APIs and matching rules shown are those of Express 4/5, Fastify's find-my-way router, and Go 1.22+ 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.

Same two routes, two frameworks
1// Express — first registered match wins
2app.get('/users/:id', getUser) // registered first
3app.get('/users/me', getMe) // dead code: never reached
4// GET /users/me -> getUser with req.params.id === 'me'
5
6// Fix: literal before parametric
7app.get('/users/me', getMe)
8app.get('/users/:id', getUser)
9
10
11// Fastify — radix trie, static segment beats parametric
12fastify.get('/users/:id', getUser)
13fastify.get('/users/me', getMe) // order irrelevant
14// GET /users/me -> getMe
15
16// Go 1.22+ — most specific pattern wins; ambiguity panics
17mux.HandleFunc("GET /users/{id}", getUser)
18mux.HandleFunc("GET /users/me", getMe) // order irrelevant
19// mux.HandleFunc("GET /a/{x}/c", h1); mux.HandleFunc("GET /a/b/{y}", h2)
20// -> panic at registration: neither is more specific than the other

The 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.

Two ways to be confident about resolution
Confident by reading
// 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/*
Confident by assertion
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.

TriggerSymptomCauseResponse
/users/:id registered above /users/me400, 500, or the wrong user returnedFirst-match router; the parametric route shadows the literal oneMove literal routes above parametric ones and assert it with a resolution test
SPA fallback mounted before the API routerClient receives HTML with status 200 and fails at JSON.parseCatch-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 301POST body disappears; the request arrives as a GETClients historically downgrade the method on 301/302Use 307/308, or normalise the path before routing rather than redirecting
Route added in a new module, imported laterNew endpoint 404s in production, works in its own testA broader pattern registered earlier already matchedDiff the startup route table in CI; fail the build on an unreachable route
Public route registered above an authenticated oneData served without authenticationShadowing bypassed the route that carried the auth middlewareApply authentication globally with an explicit public allowlist, not per-route (Middleware Ordering Is a Correctness Decision)
Gateway path rule and application router disagreeA path the gateway denies is served by the appDifferent normalisation or precedence rules at two hopsEnforce 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/me is fine, but if me collides with a real id space, /me at 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

Failure modes
  • 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).
What can race
  • 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).
Security
  • 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).
Misreads
  • "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

How you see it in production
  • 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).
What changes at 10x and 100x
  • 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.
What this costs
  • 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.

OS & Networkingproxies