Route Precedence and Specificity Rules

After reading this you will be able to predict which route wins when several patterns match the same URL, score a route table deterministically instead of relying on registration order, and write tests that catch a precedence regression before it ships.

← Back to Route Matching Algorithms

Prerequisites

Core Concept

Ambiguity is normal in a route table, not a mistake. /users/new and /users/:id both match /users/new; /docs/:section and /docs/*path both match /docs/api. A router needs a total order over matching patterns so the answer is the same on every request, in every environment, in every build.

Two strategies exist, and only one of them is safe.

Registration order takes the first pattern that matches. It is trivial to implement and it is the reason routing bugs appear after unrelated refactors: an import reordered by a bundler, a route moved during a merge, or a lazily registered feature module arriving earlier than before silently changes which view renders. Nothing in the source says the order is load-bearing.

Specificity scoring ranks patterns by how constrained they are, independent of the order they were declared. Every mainstream router converges on the same intuitive ranking, segment by segment:

  1. A static segment is the most specific — it matches exactly one string.
  2. A dynamic segment matches any single segment.
  3. An optional segment matches one or zero segments.
  4. A wildcard is least specific — it matches everything remaining.

Comparison proceeds left to right, first difference wins, and a longer pattern beats a shorter one when every compared segment ties. That single rule set explains almost every real-world case: /users/new beats /users/:id at segment two; /docs/api/v2 beats /docs/:section/v2 at segment two; /files/:name beats /files/*rest at segment two.

Segment-by-segment specificity comparison Three candidate patterns are compared for the URL slash users slash new. All tie on the first segment. On the second segment the static pattern outranks the dynamic one, which outranks the wildcard, so the static pattern wins regardless of registration order. URL: /users/new candidate segment 1 segment 2 rank /users/new static · 3 static · 3 wins /users/:id static · 3 dynamic · 2 2nd /users/*rest static · 3 wildcard · 0 3rd
Compare left to right and stop at the first difference: segment one ties for all three, segment two decides the whole ranking.

Implementation

Score each pattern once at registration time, sort, and match in ranked order. Scoring per segment rather than producing a single number keeps the comparison honest — a single number invariably collides for patterns that should be distinguishable.

// TypeScript 5.x — a deterministic specificity ranking, no dependencies

/** Higher is more specific. Deliberately sparse so ranks can be inserted later. */
const enum Rank {
  Wildcard = 0,
  Optional = 1,
  Dynamic = 2,
  Static = 3,
}

function rankSegment(segment: string): Rank {
  if (segment.startsWith("*")) return Rank.Wildcard;
  if (segment.endsWith("?")) return Rank.Optional;
  if (segment.startsWith(":")) return Rank.Dynamic;
  return Rank.Static;
}

export function scorePattern(pattern: string): Rank[] {
  return pattern.split("/").filter(Boolean).map(rankSegment);
}

/** Sort comparator: returns negative when `a` is MORE specific than `b`. */
export function compareSpecificity(a: string, b: string): number {
  const sa = scorePattern(a);
  const sb = scorePattern(b);

  const shared = Math.min(sa.length, sb.length);
  for (let i = 0; i < shared; i++) {
    if (sa[i] !== sb[i]) return sb[i] - sa[i]; // first difference decides
  }

  // All compared segments tie — the longer, more constrained pattern wins.
  if (sa.length !== sb.length) return sb.length - sa.length;

  // Genuinely identical shapes: this is an ambiguity the table should not contain.
  return 0;
}

Registration then becomes order-independent, and equal-shaped patterns can be rejected instead of silently resolved:

// TypeScript 5.x — build a ranked table and refuse ambiguity

export function buildTable(routes: { pattern: string; handler: unknown }[]) {
  const sorted = [...routes].sort((a, b) => compareSpecificity(a.pattern, b.pattern));

  for (let i = 1; i < sorted.length; i++) {
    if (compareSpecificity(sorted[i - 1].pattern, sorted[i].pattern) !== 0) continue;
    // Two patterns of identical shape: the winner would depend on sort stability.
    throw new Error(
      `Ambiguous routes: "${sorted[i - 1].pattern}" and "${sorted[i].pattern}" ` +
        `have the same specificity. Rename one, or add a constraint.`,
    );
  }

  return sorted;
}

Throwing at build time is the important part. An ambiguous pair resolved by sort stability works perfectly until the table grows past the engine’s insertion-sort threshold, at which point the winner changes for reasons no one will connect to the bug report.

Registration order versus specificity ranking Two pipelines. In the registration-order pipeline a refactor that reorders imports changes which route wins. In the specificity pipeline the table is scored and sorted at build time, so import order has no effect on the result. Registration order imports order is meaningful first match wins no ranking step a merge, a bundler change or a lazy module silently changes which view renders Specificity ranking imports order is irrelevant score + sort once, at build time same winner in every build and environment ambiguous pairs fail loudly instead of resolving
Ranking moves precedence from an emergent property of the build into an explicit, testable rule.

Verification

Precedence deserves a table-driven test, because the interesting cases are pairs rather than individual routes.

// TypeScript 5.x — table-driven precedence assertions
import { compareSpecificity } from "./specificity";

const moreSpecificFirst: [string, string][] = [
  ["/users/new", "/users/:id"],          // static beats dynamic
  ["/users/:id", "/users/*rest"],        // dynamic beats wildcard
  ["/docs/api/v2", "/docs/:section/v2"], // decided at segment two
  ["/a/b/c", "/a/b"],                    // longer wins when all shared ties
  ["/files/:name", "/files/:name?"],     // required beats optional
];

for (const [winner, loser] of moreSpecificFirst) {
  console.assert(
    compareSpecificity(winner, loser) < 0,
    `expected ${winner} to outrank ${loser}`,
  );
}

// And the table itself must contain no ambiguous pairs.
console.assert(() => {
  try {
    buildTable(appRoutes);
    return true;
  } catch {
    return false;
  }
});

The end-to-end check is one assertion per overlap: navigate to the URL where two patterns collide and assert the specific view renders. /users/new rendering the user-detail view with id: "new" is the classic symptom, and it fails in a very human way — a form that says “editing user new”.

Two patterns of identical shape have no correct winner Two routes, slash users slash colon id and slash users slash colon slug, score identically at every segment. The tie is resolved by sort stability, which changes with table size, so the build should reject the pair instead. The one case scoring cannot resolve /users/:id static · dynamic /users/:slug static · dynamic = the winner is whichever the sort happened to leave first stable below the engine's insertion-sort threshold, unstable above it — so it flips as the table grows correct response: fail the build and make the author rename one
Identical shapes are the one ambiguity ranking cannot decide — so the table should refuse to contain them.

Gotchas

  • Optional segments create two shapes from one pattern. /files/:name? matches both /files and /files/report; score it as its own rank rather than folding it into dynamic, or it will outrank a genuine static match at the shorter length.
  • Trailing slashes must be normalised before scoring. Otherwise /users/ and /users score differently and one of them quietly stops matching.
  • Constrained dynamic segments are more specific than unconstrained ones. If your router supports :id(\\d+), give it a rank between dynamic and static, or a numeric route will lose to a plain one.
  • A trie gets precedence for free. Static children are consulted before dynamic ones at every node, which is the same rule expressed structurally — one more reason to prefer it once the table is large.
  • Nested tables rank within their level. Sorting a flattened list of full paths gives different results from sorting each level’s children; rank per level to match how nested routers actually resolve.

FAQ

Does every router use the same rules? The static-dynamic-wildcard ordering is universal. The details differ: some rank by total segment count first, some treat optional segments as two patterns, and file-system routers add their own conventions for groups and catch-alls. Verify with a test rather than trusting a general rule.

Is registration order ever acceptable? For a table of half a dozen non-overlapping routes, yes. As soon as two patterns can match one URL, the order becomes load-bearing without being documented, and that is the point at which it should be replaced.

What should happen when two patterns are genuinely identical in shape? Fail the build. There is no correct answer at runtime, and picking one silently guarantees the choice will change under you later.

How does this interact with route-level guards? Precedence selects the route; guards then decide whether it may render. A guard that redirects is not a fallback to the second-place route — if you need that behaviour, express it as an explicit route rather than as guard side effects.

Where does the not-found route sit? Last, always, as a bare wildcard. Because a wildcard is the lowest rank, correct scoring puts it there without any special case.