Trie-Based Route Matching Explained
After reading this you will be able to build a segment trie that resolves any URL in time proportional to its depth rather than to the number of registered routes, and to explain exactly why a 2,000-route table stops being a performance problem once you do.
← Back to Route Matching Algorithms
Prerequisites
Core Concept
A linear matcher compares the incoming path against every registered pattern until one matches. The cost is O(n) in the number of routes, and — because each comparison is usually a regular expression — the constant factor is large. A table of 500 routes means up to 500 regex executions per navigation, every navigation.
A segment trie exploits a fact the linear scan ignores: URL paths are hierarchical, and routes that share a prefix share work. Split the path on / and walk a tree of nodes, one node per segment. At each step there are only three possibilities, checked in a fixed order:
- A static child whose key equals the segment exactly — an O(1) map lookup.
- A dynamic child that accepts any single segment and binds it to a parameter name.
- A wildcard child that consumes the remainder of the path.
The cost of a lookup is therefore the number of segments in the URL — its depth, k — multiplied by a tiny constant. Adding routes deepens or widens the tree but does not lengthen any individual walk. A path of four segments costs four steps whether the table holds ten routes or ten thousand.
The static-then-dynamic-then-wildcard ordering is not an implementation convenience; it is the specificity rule, expressed structurally. Because a static child is checked before a dynamic one at each node, /users/new beats /users/:id automatically, with no scoring pass and no sorting of the route table. The related subtleties are covered in route precedence and specificity rules.
/users/new never reaches :id.Implementation
The whole matcher is a node type, an insert function, and a recursive walk.
// TypeScript 5.x — a segment trie router, no dependencies
interface TrieNode<H> {
/** Exact-match children, keyed by segment text. */
statics: Map<string, TrieNode<H>>;
/** Single dynamic child; its name is the parameter it binds. */
dynamic?: { name: string; node: TrieNode<H> };
/** Catch-all child; consumes every remaining segment. */
wildcard?: { name: string; handler: H };
/** Present when a route terminates exactly here. */
handler?: H;
}
function createNode<H>(): TrieNode<H> {
return { statics: new Map() };
}
export function insert<H>(root: TrieNode<H>, pattern: string, handler: H): void {
const segments = pattern.split("/").filter(Boolean);
let node = root;
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
if (segment.startsWith("*")) {
// A wildcard must be the last segment; everything after it is unreachable.
node.wildcard = { name: segment.slice(1) || "rest", handler };
return;
}
if (segment.startsWith(":")) {
const name = segment.slice(1);
// One dynamic child per node: two would be ambiguous, not more expressive.
node.dynamic ??= { name, node: createNode<H>() };
node = node.dynamic.node;
continue;
}
let next = node.statics.get(segment);
if (!next) {
next = createNode<H>();
node.statics.set(segment, next);
}
node = next;
}
node.handler = handler;
}
The lookup mirrors the insert, with one addition: because a dynamic branch can turn out to be a dead end deeper in the tree, the walk must be able to backtrack to the wildcard.
// TypeScript 5.x — recursive lookup with ordered fallbacks
export interface Match<H> {
handler: H;
params: Record<string, string>;
}
export function lookup<H>(
node: TrieNode<H>,
segments: string[],
index = 0,
params: Record<string, string> = {},
): Match<H> | null {
if (index === segments.length) {
return node.handler ? { handler: node.handler, params } : null;
}
const segment = segments[index];
// 1. Static — an O(1) map hit, and the most specific option.
const staticChild = node.statics.get(segment);
if (staticChild) {
const hit = lookup(staticChild, segments, index + 1, params);
if (hit) return hit; // only return on success, so we can still try dynamic
}
// 2. Dynamic — bind the segment and descend.
if (node.dynamic) {
const hit = lookup(node.dynamic.node, segments, index + 1, {
...params,
[node.dynamic.name]: decodeURIComponent(segment),
});
if (hit) return hit;
}
// 3. Wildcard — swallow everything that is left.
if (node.wildcard) {
return {
handler: node.wildcard.handler,
params: { ...params, [node.wildcard.name]: segments.slice(index).join("/") },
};
}
return null;
}
The if (hit) return hit pattern is what makes this correct rather than merely fast. Committing to the static branch unconditionally would break /users/new/edit when new exists as a static child but has no edit beneath it while :id/edit does. Trying the branch and falling through on failure keeps specificity ordering and completeness.
Verification
// TypeScript 5.x — the assertions that matter, runnable under any test runner
const root = createNode<string>();
insert(root, "/users/new", "new-user");
insert(root, "/users/:id", "user-detail");
insert(root, "/users/:id/activity", "user-activity");
insert(root, "/docs/*path", "docs-catch-all");
const run = (p: string) => lookup(root, p.split("/").filter(Boolean));
console.assert(run("/users/new")?.handler === "new-user"); // static wins
console.assert(run("/users/42")?.handler === "user-detail"); // dynamic binds
console.assert(run("/users/42")?.params.id === "42");
console.assert(run("/users/42/activity")?.handler === "user-activity");
console.assert(run("/docs/a/b/c")?.params.path === "a/b/c"); // wildcard swallows
console.assert(run("/users") === null); // no partial matches
To confirm the complexity claim rather than assume it, time a lookup against tables of increasing size:
// TypeScript 5.x — a rough but convincing benchmark
for (const size of [10, 100, 1000, 5000]) {
const trie = createNode<number>();
for (let i = 0; i < size; i++) insert(trie, `/section-${i}/:id/detail`, i);
const start = performance.now();
for (let i = 0; i < 100_000; i++) lookup(trie, ["section-7", "42", "detail"]);
console.log(size, (performance.now() - start).toFixed(1), "ms");
}
The four timings should be within noise of each other. If they climb with size, a static lookup has become a linear scan somewhere — usually because the statics map was replaced by an array during a refactor.
Gotchas
- Two dynamic children on one node are ambiguous, not expressive.
/users/:idand/users/:slugdescribe the same shape; the second silently wins or loses depending on insertion order. Reject it at insert time. - A wildcard is only valid as the final segment. Anything registered after it is unreachable, and failing loudly at insert time saves an afternoon of debugging later.
- Decode parameters, not the whole path. Decoding before splitting turns an encoded
%2Finside a parameter into a segment separator, which changes the match. - Empty segments from double slashes.
//userssplits to["", "users"]unless you filter; normalise the path once at the entry point rather than defending inside the walk. - The trie must be built once. Rebuilding it per navigation converts an O(k) lookup into an O(n) construction and is a surprisingly common regression when route tables move into a component.
FAQ
Is a trie worth it for a small app? Below about fifty routes the difference is unmeasurable, and a regex list is simpler to read. The trie earns its place when the table grows, when routes are registered dynamically by feature modules, or when matching runs on every link render for prefetch decisions.
How do query parameters fit in? They do not — matching operates on the pathname only. Parse the query separately, as described in query string state sync.
Can a trie support regex constraints on a segment? Yes: store an optional test function on the dynamic child and treat a failed test as a non-match so the walk falls through to the wildcard. You keep O(k) as long as there is at most one constrained dynamic child per node.
Why not sort the route table by specificity instead? Sorting fixes precedence but not complexity — you still compare against every pattern. The trie gets both properties from the same structure.
Does this work for nested layouts? Almost: return the chain of nodes visited rather than only the terminal handler, and you have the match chain that nested and layout routing needs.
Related
- Route Matching Algorithms — the parent comparison of regex, path-to-regexp and trie strategies.
- Route Precedence and Specificity Rules — what “more specific” means when two patterns both match.
- How to Implement Regex Route Matching in Vanilla JS — the linear approach this replaces, and when it is still the right call.