Skip to content

Commit 792d4fe

Browse files
committed
segment trie explanation
1 parent e0e0be4 commit 792d4fe

1 file changed

Lines changed: 47 additions & 11 deletions

File tree

src/blog/tanstack-router-route-matching-tree-rewrite.md

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,43 @@ One big responsibility of a router is to match a given URL pathname (e.g., `/use
1515

1616
Our previous route matching algorithm would look through every route in the route tree, and through a mix of pattern matching, manual look-aheads, and recursion, find the best match. As we added more features like optional segments and wildcards, the algorithm became increasingly complex and slow, and we started receiving reports of incorrect matches.
1717

18-
We opted for a complete rewrite: we now parse the route tree into a segment trie, and matching is done by traversing this trie. This makes it much simpler to implement exact matching rules while ensuring high performance.
18+
We opted for a complete rewrite.
19+
20+
## A Segment Trie
21+
22+
We now parse the route tree into a segment trie, and matching is done by traversing this trie. This makes it much simpler to implement exact matching rules while ensuring high performance.
23+
24+
A trie ([wikipedia](https://en.wikipedia.org/wiki/Trie)) is a tree structure where each node corresponds to the common string prefix shared by all of the node's children. The concept maps very well to a representation of the routes in an app, where each node is a URL pathname segment.
25+
26+
Given a single route `/users/$id`, our segment trie would look like this:
27+
```
28+
root
29+
└── users
30+
└── $id => match /users/$id
31+
```
32+
33+
We can add more routes to get a complete picture:
34+
```
35+
/users/$id
36+
/users/$id/posts
37+
/users/profile
38+
/posts/$slug
39+
```
40+
This yields the following tree:
41+
```
42+
root
43+
├── users
44+
│ ├── $id => match /users/$id
45+
│ │ └── posts => match /users/$id/posts
46+
│ └── profile => match /users/profile
47+
└── posts
48+
└── $slug => match /posts/$slug
49+
```
50+
51+
To match `/users/123`, we:
52+
1. Start at root, look for "users" → found
53+
2. Move to users node, look for "123" → matches $id pattern
54+
3. Check if this node has a route → yes, return `/users/$id`
1955

2056
## Algorithmic Complexity
2157

@@ -26,9 +62,9 @@ The reason we can get such a massive performance boost is because we've changed
2662

2763
(This is very simplified, it's probably more something like `O(N * M)` vs. `O(M * log(N))`, but the point stands: we're scaling differently now.)
2864

29-
Using this new trie structure, each check eliminates a large number of possible routes, allowing us to quickly zero in on the correct match.
65+
Using this new tree structure, each check eliminates a large number of possible routes, allowing us to quickly zero in on the correct match.
3066

31-
For example, imagine we have a route tree with 450 routes (fairly large app) and the tree can only eliminate 50% of routes at each segment check (this is unusually bad, it's often much higher). With this bad setup, we have found a match in 9 checks (`2**9 > 450`). By contrast, the old approach *could* have found the match on the first check, but in the worst case it would have had to check all 450 routes, which yields an average of 225 checks. Even in this simplified case, we are looking at a 25× performance improvement.
67+
For example, imagine we have a route tree with 450 routes (fairly large app) and the tree can only eliminate 50% of routes at each segment check (this is unusually low, it's often much higher). With this bad setup, we have found a match in 9 checks (`2**9 > 450`). By contrast, the old approach *could* have found the match on the first check, but in the worst case it would have had to check all 450 routes, which yields an average of 225 checks. Even in this simplified case, we are looking at a 25× performance improvement.
3268

3369
This is what makes tree structures so powerful.
3470

@@ -45,11 +81,11 @@ Beyond choosing the right data structures, working on performance is usually abo
4581

4682
### Backwards Stack Processing
4783

48-
We use a stack to manage our traversal of the trie, because the presence of dynamic segments (`/$required`, `/{-$optional}`, `/$` wildcards) means we may have multiple possible paths to explore at each segment.
84+
We use a stack to manage our traversal of the tree, because the presence of dynamic segments (`/$required`, `/{-$optional}`, `/$` wildcards) means we may have multiple possible paths to explore at each segment.
4985

5086
The ideal algorithm would be depth-first search (DFS) in order of highest priority, so that we can return as soon as we find a match. In practice, we have very few possibilities of early exit; but a fully static path should still be able to return immediately.
5187

52-
To accomplish this, we use an array as the stack. Since pushing and popping at the end of an array are O(1) operations, while shifting from the start is O(N), we avoid the latter entirely. At each segment, we iterate candidates in *reverse* order of priority, pushing them onto the stack. This way, when we pop from the stack, we get the highest priority candidate first.
88+
To accomplish this, we use an array as the stack. We know that `.push()` and `.pop()` at the end of an array are O(1) operations, while `.shift()` and `.unshift()` from the start are O(N), and we want to avoid the latter entirely. At each segment, we iterate candidates in *reverse* order of priority, pushing them onto the stack. This way, when we pop from the stack, we get the highest priority candidates first.
5389

5490
```ts
5591
const stack = [
@@ -79,9 +115,9 @@ while (stack.length) {
79115

80116
### Bitmasking for Optional Segments
81117

82-
Optional segments introduce additional complexity, as they can be present or absent in the URL. While walking the trie, we need to track which optional segments were skipped (i.e. an array of booleans).
118+
Optional segments introduce additional complexity, as they can be present or absent in the URL. While walking the tree, we need to track which optional segments were skipped (i.e. we need an array of booleans).
83119

84-
Every time we push onto the stack, we need to store the "state at which to pick up from" including which optional segments were skipped. But we don't want to have to `[...copy]` an array of booleans every time we push onto the stack as it would imply many short-lived allocations.
120+
Every time we push onto the stack, we need to store the "state at which to pick up from" including which optional segments were skipped. But we don't want to have to `[...copy]` an array of booleans every time we push onto the stack as it would create many short-lived allocations.
85121

86122
To avoid this overhead, we use bitmasking to represent skipped optional segments.
87123

@@ -104,7 +140,7 @@ And to read from the bitmask:
104140
if (skipped & (1 << depth)) // segment at 'depth' was skipped
105141
```
106142

107-
The downside is that this limits us to 32 segments. Optional segments beyond that point will never be considered skipped. We could switch to a `BigInt` if needed, but for now, this feels reasonable.
143+
The downside is that this limits us to 32 segments, because in JavaScript bitwise operations cast a number into a 32-bit integer. Optional segments beyond that point will never be considered skipped. We could switch to a `BigInt` if needed, but for now, this feels reasonable.
108144

109145
### Reusing Typed Arrays for Segment Parsing
110146

@@ -136,7 +172,7 @@ function parseSegment(
136172
path: string,
137173
cursor: number,
138174
data: Uint16Array = new Uint16Array(6)
139-
): Segment {}
175+
): Segment
140176
```
141177

142178
### Least Recently Used (LRU) Caching
@@ -166,15 +202,15 @@ This data structure performs about half as well as a regular `Object` for writes
166202

167203
## The full story
168204

169-
The numbers we've presented so far are impressive. They're also cherry-picked from the biggest apps we tested, which is biased in favor of the new algorithm. They're also comparisons against the old, uncached algorithm. In reality, we've added caching a while ago. We can see the full progression over the last 4 months:
205+
The numbers we've presented so far are impressive. They're also cherry-picked from the biggest apps we tested, which is biased in favor of the new algorithm. And they're comparisons against the old, uncached algorithm. In reality, we've added caching a while ago. We can see the full progression over the last 4 months:
170206

171207
![route matching performance over 4 evolutions of the algorithm](/blog-assets/tanstack-router-route-matching-tree-rewrite/matching-evolution-benchmark.png)
172208

173209
And besides that, they also focus on a small part of the router's performance profile. Matching a pathname to a route is only one part of the job. If we look at a more "complete" operation, for example `buildLocation`, which involves matching, building the location object, interpolating the path, passing the validation functions, running the middlewares, etc, we see a more modest but still significant improvement:
174210

175211
![buildLocation performance over 4 evolutions of the algorithm](/blog-assets/tanstack-router-route-matching-tree-rewrite/buildlocation-evolution-benchmark.png)
176212

177-
Even the smallest apps see some improvement here, but it might not feel as dramatic. We will continue to optimize the other parts of the router to make it feel as snappy as we can. The good news is route matching is no longer a bottleneck!
213+
Even the smallest apps see some improvement here, but it might not feel as dramatic. We will continue to optimize the other parts of the router to make it feel as snappy as we can. The good news is route matching is no longer a bottleneck.
178214

179215
## Going even further
180216

0 commit comments

Comments
 (0)