Skip to content

Commit 182df4d

Browse files
authored
refactor(router-core): add LRU cache in front of parsePathname (#4752)
We noticed that `parsePathname` is a performance bottleneck during navigation events. Here's a flamegraph from an application w/ ~300 routes around a navigation event: <img width="853" height="372" alt="Screenshot 2025-07-22 at 19 36 24" src="https://github.com/user-attachments/assets/7ed2e09a-8ea9-4170-862a-2d53c3eb4793" /> This PR proposes a basic least-recently-used cache implementation (that data structure is about 4-5x slower than a `Map` according to benchmarks, and it uses a little more memory). We can add caching to `parsePathname` now that what it returns is readonly (#4705), which should improve the performance of this bottleneck. The cache is set to a size of 1000. When / if we end up making a pre-built matcher (WIP #4714), we maybe can reduce this size. But `parsePathname` is still called w/ built pathnames so we probably shouldn't remove the cache entirely. When benchmarking `matchPathname` with and without the cache, we notice a ~9x increase in throughput with the cache. ``` ✓ @tanstack/router-core tests/cache.bench.ts > cache.bench 7191ms name hz min max mean p75 p99 p995 p999 rme samples · original 1,795.61 0.5055 0.7022 0.5569 0.5604 0.6234 0.6464 0.7022 ±0.22% 898 · cached 16,307.87 0.0562 0.2294 0.0613 0.0608 0.0767 0.1007 0.1152 ±0.17% 8154 fastest BENCH Summary @tanstack/router-core cached - tests/cache.bench.ts > cache.bench 9.08x faster than original ``` Assuming this 9x increase translates to a proportional reduction of the self-time seen in the flamegraph, it would go from 55ms to 6ms.
1 parent 1cd9176 commit 182df4d

File tree

4 files changed

+172
-27
lines changed

4 files changed

+172
-27
lines changed

packages/router-core/src/lru-cache.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
export type LRUCache<TKey, TValue> = {
2+
get: (key: TKey) => TValue | undefined
3+
set: (key: TKey, value: TValue) => void
4+
}
5+
6+
export function createLRUCache<TKey, TValue>(
7+
max: number,
8+
): LRUCache<TKey, TValue> {
9+
type Node = { prev?: Node; next?: Node; key: TKey; value: TValue }
10+
const cache = new Map<TKey, Node>()
11+
let oldest: Node | undefined
12+
let newest: Node | undefined
13+
14+
const touch = (entry: Node) => {
15+
if (!entry.next) return
16+
if (!entry.prev) {
17+
entry.next.prev = undefined
18+
oldest = entry.next
19+
entry.next = undefined
20+
if (newest) {
21+
entry.prev = newest
22+
newest.next = entry
23+
}
24+
} else {
25+
entry.prev.next = entry.next
26+
entry.next.prev = entry.prev
27+
entry.next = undefined
28+
if (newest) {
29+
newest.next = entry
30+
entry.prev = newest
31+
}
32+
}
33+
newest = entry
34+
}
35+
36+
return {
37+
get(key) {
38+
const entry = cache.get(key)
39+
if (!entry) return undefined
40+
touch(entry)
41+
return entry.value
42+
},
43+
set(key, value) {
44+
if (cache.size >= max && oldest) {
45+
const toDelete = oldest
46+
cache.delete(toDelete.key)
47+
if (toDelete.next) {
48+
oldest = toDelete.next
49+
toDelete.next.prev = undefined
50+
}
51+
if (toDelete === newest) {
52+
newest = undefined
53+
}
54+
}
55+
const existing = cache.get(key)
56+
if (existing) {
57+
existing.value = value
58+
touch(existing)
59+
} else {
60+
const entry: Node = { key, value, prev: newest }
61+
if (newest) newest.next = entry
62+
newest = entry
63+
if (!oldest) oldest = entry
64+
cache.set(key, entry)
65+
}
66+
},
67+
}
68+
}

packages/router-core/src/path.ts

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { last } from './utils'
2+
import type { LRUCache } from './lru-cache'
23
import type { MatchLocation } from './RouterProvider'
34
import type { AnyPathParams } from './route'
45

@@ -101,6 +102,7 @@ interface ResolvePathOptions {
101102
to: string
102103
trailingSlash?: 'always' | 'never' | 'preserve'
103104
caseSensitive?: boolean
105+
parseCache?: ParsePathnameCache
104106
}
105107

106108
function segmentToString(segment: Segment): string {
@@ -154,12 +156,13 @@ export function resolvePath({
154156
to,
155157
trailingSlash = 'never',
156158
caseSensitive,
159+
parseCache,
157160
}: ResolvePathOptions) {
158161
base = removeBasepath(basepath, base, caseSensitive)
159162
to = removeBasepath(basepath, to, caseSensitive)
160163

161-
let baseSegments = parsePathname(base).slice()
162-
const toSegments = parsePathname(to)
164+
let baseSegments = parsePathname(base, parseCache).slice()
165+
const toSegments = parsePathname(to, parseCache)
163166

164167
if (baseSegments.length > 1 && last(baseSegments)?.value === '/') {
165168
baseSegments.pop()
@@ -202,6 +205,19 @@ export function resolvePath({
202205
return joined
203206
}
204207

208+
export type ParsePathnameCache = LRUCache<string, ReadonlyArray<Segment>>
209+
export const parsePathname = (
210+
pathname?: string,
211+
cache?: ParsePathnameCache,
212+
): ReadonlyArray<Segment> => {
213+
if (!pathname) return []
214+
const cached = cache?.get(pathname)
215+
if (cached) return cached
216+
const parsed = baseParsePathname(pathname)
217+
cache?.set(pathname, parsed)
218+
return parsed
219+
}
220+
205221
const PARAM_RE = /^\$.{1,}$/ // $paramName
206222
const PARAM_W_CURLY_BRACES_RE = /^(.*?)\{(\$[a-zA-Z_$][a-zA-Z0-9_$]*)\}(.*)$/ // prefix{$paramName}suffix
207223
const OPTIONAL_PARAM_W_CURLY_BRACES_RE =
@@ -227,11 +243,7 @@ const WILDCARD_W_CURLY_BRACES_RE = /^(.*?)\{\$\}(.*)$/ // prefix{$}suffix
227243
* - `/foo/[$]{$foo} - Dynamic route with a static prefix of `$`
228244
* - `/foo/{$foo}[$]` - Dynamic route with a static suffix of `$`
229245
*/
230-
export function parsePathname(pathname?: string): ReadonlyArray<Segment> {
231-
if (!pathname) {
232-
return []
233-
}
234-
246+
function baseParsePathname(pathname: string): ReadonlyArray<Segment> {
235247
pathname = cleanPath(pathname)
236248

237249
const segments: Array<Segment> = []
@@ -348,6 +360,7 @@ interface InterpolatePathOptions {
348360
leaveParams?: boolean
349361
// Map of encoded chars to decoded chars (e.g. '%40' -> '@') that should remain decoded in path params
350362
decodeCharMap?: Map<string, string>
363+
parseCache?: ParsePathnameCache
351364
}
352365

353366
type InterPolatePathResult = {
@@ -361,8 +374,9 @@ export function interpolatePath({
361374
leaveWildcards,
362375
leaveParams,
363376
decodeCharMap,
377+
parseCache,
364378
}: InterpolatePathOptions): InterPolatePathResult {
365-
const interpolatedPathSegments = parsePathname(path)
379+
const interpolatedPathSegments = parsePathname(path, parseCache)
366380

367381
function encodeParam(key: string): any {
368382
const value = params[key]
@@ -480,8 +494,14 @@ export function matchPathname(
480494
basepath: string,
481495
currentPathname: string,
482496
matchLocation: Pick<MatchLocation, 'to' | 'fuzzy' | 'caseSensitive'>,
497+
parseCache?: ParsePathnameCache,
483498
): AnyPathParams | undefined {
484-
const pathParams = matchByPath(basepath, currentPathname, matchLocation)
499+
const pathParams = matchByPath(
500+
basepath,
501+
currentPathname,
502+
matchLocation,
503+
parseCache,
504+
)
485505
// const searchMatched = matchBySearch(location.search, matchLocation)
486506

487507
if (matchLocation.to && !pathParams) {
@@ -540,6 +560,7 @@ export function matchByPath(
540560
fuzzy,
541561
caseSensitive,
542562
}: Pick<MatchLocation, 'to' | 'caseSensitive' | 'fuzzy'>,
563+
parseCache?: ParsePathnameCache,
543564
): Record<string, string> | undefined {
544565
// check basepath first
545566
if (basepath !== '/' && !from.startsWith(basepath)) {
@@ -551,8 +572,14 @@ export function matchByPath(
551572
to = removeBasepath(basepath, `${to ?? '$'}`, caseSensitive)
552573

553574
// Parse the from and to
554-
const baseSegments = parsePathname(from.startsWith('/') ? from : `/${from}`)
555-
const routeSegments = parsePathname(to.startsWith('/') ? to : `/${to}`)
575+
const baseSegments = parsePathname(
576+
from.startsWith('/') ? from : `/${from}`,
577+
parseCache,
578+
)
579+
const routeSegments = parsePathname(
580+
to.startsWith('/') ? to : `/${to}`,
581+
parseCache,
582+
)
556583

557584
const params: Record<string, string> = {}
558585

packages/router-core/src/router.ts

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ import { setupScrollRestoration } from './scroll-restoration'
3333
import { defaultParseSearch, defaultStringifySearch } from './searchParams'
3434
import { rootRouteId } from './root'
3535
import { isRedirect, redirect } from './redirect'
36-
import type { Segment } from './path'
36+
import { createLRUCache } from './lru-cache'
37+
import type { ParsePathnameCache, Segment } from './path'
3738
import type { SearchParser, SearchSerializer } from './searchParams'
3839
import type { AnyRedirect, ResolvedRedirect } from './redirect'
3940
import type {
@@ -1014,6 +1015,7 @@ export class RouterCore<
10141015
to: cleanPath(path),
10151016
trailingSlash: this.options.trailingSlash,
10161017
caseSensitive: this.options.caseSensitive,
1018+
parseCache: this.parsePathnameCache,
10171019
})
10181020
return resolvedPath
10191021
}
@@ -1195,6 +1197,7 @@ export class RouterCore<
11951197
params: routeParams,
11961198
leaveWildcards: true,
11971199
decodeCharMap: this.pathParamsDecodeCharMap,
1200+
parseCache: this.parsePathnameCache,
11981201
}).interpolatedPath + loaderDepsHash
11991202

12001203
// Waste not, want not. If we already have a match for this route,
@@ -1333,6 +1336,9 @@ export class RouterCore<
13331336
return matches
13341337
}
13351338

1339+
/** a cache for `parsePathname` */
1340+
private parsePathnameCache: ParsePathnameCache = createLRUCache(1000)
1341+
13361342
getMatchedRoutes: GetMatchRoutesFn = (
13371343
pathname: string,
13381344
routePathname: string | undefined,
@@ -1345,6 +1351,7 @@ export class RouterCore<
13451351
routesByPath: this.routesByPath,
13461352
routesById: this.routesById,
13471353
flatRoutes: this.flatRoutes,
1354+
parseCache: this.parsePathnameCache,
13481355
})
13491356
}
13501357

@@ -1452,6 +1459,7 @@ export class RouterCore<
14521459
const interpolatedNextTo = interpolatePath({
14531460
path: nextTo,
14541461
params: nextParams ?? {},
1462+
parseCache: this.parsePathnameCache,
14551463
}).interpolatedPath
14561464

14571465
const destRoutes = this.matchRoutes(
@@ -1484,6 +1492,7 @@ export class RouterCore<
14841492
leaveWildcards: false,
14851493
leaveParams: opts.leaveParams,
14861494
decodeCharMap: this.pathParamsDecodeCharMap,
1495+
parseCache: this.parsePathnameCache,
14871496
}).interpolatedPath
14881497

14891498
// Resolve the next search
@@ -1567,11 +1576,16 @@ export class RouterCore<
15671576
let params = {}
15681577

15691578
const foundMask = this.options.routeMasks?.find((d) => {
1570-
const match = matchPathname(this.basepath, next.pathname, {
1571-
to: d.from,
1572-
caseSensitive: false,
1573-
fuzzy: false,
1574-
})
1579+
const match = matchPathname(
1580+
this.basepath,
1581+
next.pathname,
1582+
{
1583+
to: d.from,
1584+
caseSensitive: false,
1585+
fuzzy: false,
1586+
},
1587+
this.parsePathnameCache,
1588+
)
15751589

15761590
if (match) {
15771591
params = match
@@ -2971,10 +2985,15 @@ export class RouterCore<
29712985
? this.latestLocation
29722986
: this.state.resolvedLocation || this.state.location
29732987

2974-
const match = matchPathname(this.basepath, baseLocation.pathname, {
2975-
...opts,
2976-
to: next.pathname,
2977-
}) as any
2988+
const match = matchPathname(
2989+
this.basepath,
2990+
baseLocation.pathname,
2991+
{
2992+
...opts,
2993+
to: next.pathname,
2994+
},
2995+
this.parsePathnameCache,
2996+
) as any
29782997

29792998
if (!match) {
29802999
return false
@@ -3368,6 +3387,7 @@ export function getMatchedRoutes<TRouteLike extends RouteLike>({
33683387
routesByPath,
33693388
routesById,
33703389
flatRoutes,
3390+
parseCache,
33713391
}: {
33723392
pathname: string
33733393
routePathname?: string
@@ -3376,16 +3396,22 @@ export function getMatchedRoutes<TRouteLike extends RouteLike>({
33763396
routesByPath: Record<string, TRouteLike>
33773397
routesById: Record<string, TRouteLike>
33783398
flatRoutes: Array<TRouteLike>
3399+
parseCache?: ParsePathnameCache
33793400
}) {
33803401
let routeParams: Record<string, string> = {}
33813402
const trimmedPath = trimPathRight(pathname)
33823403
const getMatchedParams = (route: TRouteLike) => {
3383-
const result = matchPathname(basepath, trimmedPath, {
3384-
to: route.fullPath,
3385-
caseSensitive: route.options?.caseSensitive ?? caseSensitive,
3386-
// we need fuzzy matching for `notFoundMode: 'fuzzy'`
3387-
fuzzy: true,
3388-
})
3404+
const result = matchPathname(
3405+
basepath,
3406+
trimmedPath,
3407+
{
3408+
to: route.fullPath,
3409+
caseSensitive: route.options?.caseSensitive ?? caseSensitive,
3410+
// we need fuzzy matching for `notFoundMode: 'fuzzy'`
3411+
fuzzy: true,
3412+
},
3413+
parseCache,
3414+
)
33893415
return result
33903416
}
33913417

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { createLRUCache } from '../src/lru-cache'
3+
4+
describe('LRU Cache', () => {
5+
it('evicts oldest set', () => {
6+
const cache = createLRUCache<string, number>(3)
7+
cache.set('a', 1)
8+
cache.set('b', 2)
9+
cache.set('c', 3)
10+
cache.set('d', 4) // 'a' should be evicted
11+
expect(cache.get('a')).toBeUndefined()
12+
expect(cache.get('b')).toBe(2)
13+
})
14+
it('evicts oldest used', () => {
15+
const cache = createLRUCache<string, number>(3)
16+
cache.set('a', 1)
17+
cache.set('b', 2)
18+
cache.set('c', 3)
19+
cache.get('a') // 'a' is now the most recently used
20+
cache.set('d', 4) // 'b' should be evicted
21+
expect(cache.get('b')).toBeUndefined()
22+
expect(cache.get('a')).toBe(1)
23+
})
24+
})

0 commit comments

Comments
 (0)