You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: src/blog/tanstack-router-route-matching-tree-rewrite.md
+47-11Lines changed: 47 additions & 11 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -15,7 +15,43 @@ One big responsibility of a router is to match a given URL pathname (e.g., `/use
15
15
16
16
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.
17
17
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`
19
55
20
56
## Algorithmic Complexity
21
57
@@ -26,9 +62,9 @@ The reason we can get such a massive performance boost is because we've changed
26
62
27
63
(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.)
28
64
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.
30
66
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.
32
68
33
69
This is what makes tree structures so powerful.
34
70
@@ -45,11 +81,11 @@ Beyond choosing the right data structures, working on performance is usually abo
45
81
46
82
### Backwards Stack Processing
47
83
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.
49
85
50
86
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.
51
87
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.
53
89
54
90
```ts
55
91
const stack = [
@@ -79,9 +115,9 @@ while (stack.length) {
79
115
80
116
### Bitmasking for Optional Segments
81
117
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).
83
119
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.
85
121
86
122
To avoid this overhead, we use bitmasking to represent skipped optional segments.
87
123
@@ -104,7 +140,7 @@ And to read from the bitmask:
104
140
if (skipped& (1<<depth)) // segment at 'depth' was skipped
105
141
```
106
142
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.
108
144
109
145
### Reusing Typed Arrays for Segment Parsing
110
146
@@ -136,7 +172,7 @@ function parseSegment(
136
172
path:string,
137
173
cursor:number,
138
174
data:Uint16Array=newUint16Array(6)
139
-
):Segment {}
175
+
):Segment
140
176
```
141
177
142
178
### LeastRecentlyUsed (LRU) Caching
@@ -166,15 +202,15 @@ This data structure performs about half as well as a regular `Object` for writes
166
202
167
203
## Thefullstory
168
204
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
+
Thenumberswe've presented so far are impressive. They'realsocherry-pickedfromthebiggestappswetested, whichisbiasedinfavorofthenewalgorithm. Andthey're comparisons against the old, uncached algorithm. In reality, we'veaddedcachingawhileago. Wecanseethefullprogressionoverthelast4months:
Andbesidesthat, theyalsofocusonasmallpartoftherouter'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:
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!
0 commit comments