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:
- A static segment is the most specific — it matches exactly one string.
- A dynamic segment matches any single segment.
- An optional segment matches one or zero segments.
- 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.
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.
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”.
Gotchas
- Optional segments create two shapes from one pattern.
/files/:name?matches both/filesand/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/usersscore 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.
Related
- Route Matching Algorithms — the strategies precedence rules apply to, from linear scans to tries.
- Trie-Based Route Matching Explained — a structure where specificity ordering is built in rather than computed.
- Catch-All and Splat Routes Explained — the lowest-ranked segment type and the one most likely to swallow traffic by accident.