Skip to content

Commit 3bc88c4

Browse files
authored
perf(webapp): memoize react-router per-request route matching via pnpm patch (#3877)
## Summary Under high request load the webapp spends most of its CPU inside react-router's `matchRoutes`, not in application code. `@remix-run/router@1.23.2` (the React Router v6 / Remix 2 core) re-flattens, re-ranks, and recompiles the entire route table on every request, and with the webapp's ~436 routes that cost dominates once request rates climb. There is no `NODE_ENV` gate, so production pays it too. This adds a pnpm patch that memoizes the parts that depend only on the static route manifest: it caches the flattened/ranked branches per route tree, hoists the loop-invariant `decodePath` out of the match loop, and caches compiled path regexes. ## Benchmark CPU profile over the same load (100 concurrent tag feeds, ~425 req/s), `NODE_ENV=production`, before vs after the patch: | Metric | Before | After | | --- | --- | --- | | Active CPU (self-time over the window) | 28.3s | 18.5s (-34%) | | Route-matching self-time | 19.2s | 7.5s (-61%) | | Event-loop lag p99 | 322ms | 113ms (-65%) | | Idle headroom | 26% | 52% | Application/realtime code was ~0% of CPU in both profiles; the bottleneck was entirely generic per-request route matching. ## Why a patch instead of an upgrade The inefficiency is acknowledged upstream ([remix-run/react-router#8653](remix-run/react-router#8653)). A contributor PR doing exactly this ([remix-run/react-router#14866](remix-run/react-router#14866)) was closed in favor of a narrower fix ([remix-run/react-router#14967](remix-run/react-router#14967), branch caching only, shipped in React Router v7), with the maintainer suggesting patch-package as the interim until the Remix 3 route-pattern rewrite (see [remix-run/remix#4786](remix-run/remix#4786)). We are on the v6-era core and cannot pick up even the partial fix without a framework migration, so this patch is the sanctioned stopgap, and it also includes the compiled-regex cache the merged PR left out. [`patches/README.md`](https://github.com/triggerdotdev/trigger.dev/blob/perf/react-router-route-matching/patches/README.md) documents the full rationale, the safety argument (deterministic, internal-only, bounded caches), and when to remove the patch.
1 parent d973359 commit 3bc88c4

5 files changed

Lines changed: 193 additions & 13 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Speed up the dashboard and API under high request load by memoizing react-router's per-request route matching, which previously re-flattened, re-ranked, and recompiled the entire route table on every request.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@
8686
"@upstash/ratelimit@1.1.3": "patches/@upstash__ratelimit.patch",
8787
"antlr4ts@0.5.0-alpha.4": "patches/antlr4ts@0.5.0-alpha.4.patch",
8888
"@window-splitter/state@1.1.3": "patches/@window-splitter__state@1.1.3.patch",
89-
"streamdown@2.5.0": "patches/streamdown@2.5.0.patch"
89+
"streamdown@2.5.0": "patches/streamdown@2.5.0.patch",
90+
"@remix-run/router@1.23.2": "patches/@remix-run__router@1.23.2.patch"
9091
},
9192
"overrides": {
9293
"typescript": "5.5.4",
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
diff --git a/dist/router.cjs.js b/dist/router.cjs.js
2+
index e634d45fee327b5f9ef63eee8dc1da39b07c79d4..ce1cf6c599e7efa82d51b63d26c0c92e82931083 100644
3+
--- a/dist/router.cjs.js
4+
+++ b/dist/router.cjs.js
5+
@@ -746,6 +746,11 @@ function convertRoutesToDataRoutes(routes, mapRouteProperties, parentPath, manif
6+
*
7+
* @see https://reactrouter.com/v6/utils/match-routes
8+
*/
9+
+// trigger.dev perf patch — memoize per-request route matching. See patches/README.md
10+
+// (backports the idea in react-router PR #14866, which was closed in favor of the partial
11+
+// fix #14967; maintainer suggested patch-package until the Remix 3 route-pattern rewrite).
12+
+let __branchCache = new WeakMap();
13+
+let __compileCache = new Map();
14+
function matchRoutes(routes, locationArg, basename) {
15+
if (basename === void 0) {
16+
basename = "/";
17+
@@ -758,17 +763,17 @@ function matchRoutesImpl(routes, locationArg, basename, allowPartial) {
18+
if (pathname == null) {
19+
return null;
20+
}
21+
- let branches = flattenRoutes(routes);
22+
- rankRouteBranches(branches);
23+
+ // flatten+rank depend only on `routes` (static) — cache per route-tree ref.
24+
+ let branches = __branchCache.get(routes);
25+
+ if (!branches) {
26+
+ branches = flattenRoutes(routes);
27+
+ rankRouteBranches(branches);
28+
+ __branchCache.set(routes, branches);
29+
+ }
30+
let matches = null;
31+
+ // decodePath(pathname) is loop-invariant — hoisted out (was recomputed per branch).
32+
+ let decoded = decodePath(pathname);
33+
for (let i = 0; matches == null && i < branches.length; ++i) {
34+
- // Incoming pathnames are generally encoded from either window.location
35+
- // or from router.navigate, but we want to match against the unencoded
36+
- // paths in the route definitions. Memory router locations won't be
37+
- // encoded here but there also shouldn't be anything to decode so this
38+
- // should be a safe operation. This avoids needing matchRoutes to be
39+
- // history-aware.
40+
- let decoded = decodePath(pathname);
41+
matches = matchRouteBranch(branches[i], decoded, allowPartial);
42+
}
43+
return matches;
44+
@@ -1078,6 +1083,12 @@ function compilePath(path, caseSensitive, end) {
45+
if (end === void 0) {
46+
end = true;
47+
}
48+
+ // perf patch: cache the compiled [regexp, params] by pattern (see patches/README.md).
49+
+ let __ck = path + "\0" + caseSensitive + "\0" + end;
50+
+ let __cc = __compileCache.get(__ck);
51+
+ if (__cc !== void 0) {
52+
+ return __cc;
53+
+ }
54+
warning(path === "*" || !path.endsWith("*") || path.endsWith("/*"), "Route path \"" + path + "\" will be treated as if it were " + ("\"" + path.replace(/\*$/, "/*") + "\" because the `*` character must ") + "always follow a `/` in the pattern. To get rid of this warning, " + ("please change the route path to \"" + path.replace(/\*$/, "/*") + "\"."));
55+
let params = [];
56+
let regexpSource = "^" + path.replace(/\/*\*?$/, "") // Ignore trailing / and /*, we'll handle it below
57+
@@ -1110,7 +1121,11 @@ function compilePath(path, caseSensitive, end) {
58+
regexpSource += "(?:(?=\\/|$))";
59+
} else ;
60+
let matcher = new RegExp(regexpSource, caseSensitive ? undefined : "i");
61+
- return [matcher, params];
62+
+ let __res = [matcher, params];
63+
+ // Bounded: route patterns are a static set; the cap guards any dynamic matchPath() use.
64+
+ if (__compileCache.size >= 2000) __compileCache.clear();
65+
+ __compileCache.set(__ck, __res);
66+
+ return __res;
67+
}
68+
function decodePath(value) {
69+
try {

patches/README.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Patches
2+
3+
This directory holds [pnpm patches](https://pnpm.io/cli/patch) applied on install via
4+
`pnpm.patchedDependencies` in the root `package.json`. Each `.patch` is a diff against the
5+
published package. Most are small and self-explanatory from the diff; the non-obvious ones
6+
are documented below.
7+
8+
---
9+
10+
## `@remix-run/router@1.23.2` — route-matching memoization
11+
12+
**File:** `patches/@remix-run__router@1.23.2.patch` (patches `dist/router.cjs.js`)
13+
14+
### What it does
15+
16+
Three changes to `matchRoutesImpl` / `compilePath`, all pure memoization of work that
17+
depends only on the **static** route manifest:
18+
19+
1. **Cache flattened + ranked branches per route-tree** (`WeakMap` keyed by the `routes`
20+
ref). `flattenRoutes()` + `rankRouteBranches()` were recomputed on *every* `matchRoutes`
21+
call across all ~436 webapp routes.
22+
2. **Hoist `decodePath(pathname)` out of the branch-match loop** — it's loop-invariant but
23+
was recomputed once per branch.
24+
3. **Memoize `compilePath` compiled regexes** by `path|caseSensitive|end` (bounded `Map`,
25+
cap 2000). The matcher RegExp was rebuilt on every `matchPath` call.
26+
27+
### Why
28+
29+
Profiling the realtime runs feed under load (100 concurrent tag feeds, ~425 req/s) found
30+
**~68% of webapp CPU was spent in react-router's `matchRoutes`** — re-flattening,
31+
re-ranking, and re-compiling the entire route table on every request. It is **not** a dev
32+
artifact: there is no `NODE_ENV` gate, and a `NODE_ENV=production` profile was identical
33+
(67.9% vs 68.3%). The realtime feed's high request rate (each long-poll returns fast and
34+
immediately re-polls) just amplifies a latent per-request cost that large route tables pay
35+
everywhere.
36+
37+
Measured on a single instance, same load, before vs after this patch:
38+
39+
| | before | after |
40+
|---|---|---|
41+
| active CPU (self-time / window) | 28.3s | 18.5s (**−34%**) |
42+
| route-matching self-time | 19.2s | 7.5s (**−61%**) |
43+
| event-loop lag p99 | 322ms | 113ms (**−65%**) |
44+
| idle headroom | 26% | 52% |
45+
46+
The realtime machinery itself (router/hydrate/serialize/diff) was ~0% — the bottleneck was
47+
entirely generic Remix request overhead.
48+
49+
### Upstream status (why we patch instead of upgrade)
50+
51+
This is a known, acknowledged inefficiency, and it is **only partially fixed in React
52+
Router v7** — which we can't adopt without a full Remix 2 → RR7 framework migration.
53+
54+
- [Issue #8653 "Performance issues"](https://github.com/remix-run/react-router/issues/8653)
55+
reported it (a user with 12k routes, ~67ms per match) and was closed as a dup of the
56+
route-ranking discussion [remix#4786](https://github.com/remix-run/remix/discussions/4786).
57+
- [PR #14866 "Optimize route matching performance with caching"](https://github.com/remix-run/react-router/pull/14866)
58+
implemented *exactly this patch* (hoist `decodePath`, cache `compilePath`, cache
59+
flatten/rank), claiming **~80% route-matching CPU reduction on a 400+ route app**. It was
60+
**closed, not merged.**
61+
- [PR #14967 "perf: cache flattened/ranked route branches"](https://github.com/remix-run/react-router/pull/14967)
62+
is the partial fix that *did* ship (in v7): it caches only the branches, threaded via a
63+
`precomputedBranches` param through the framework's server-runtime (~15% SSR gain). It
64+
does **not** cache `compilePath` — that regex rebuild remains even on `main`.
65+
([PR #14971](https://github.com/remix-run/react-router/pull/14971) added client-side wins.)
66+
67+
The maintainer's reasoning for closing the fuller PR (#14866), verbatim:
68+
69+
> "This is great as a `patch-package` optimization for those who want it, but we are
70+
> actively working on integrating the more performant route-pattern library from Remix 3 so
71+
> we'd rather just do the right 'fix' and ship the new algorithm instead of trying to
72+
> band-aide perf improvements to the existing algorithm which was written with a very
73+
> different set of constraints. Those constraints come from early v6 when it was only
74+
> declarative mode so route trees were defined at render time and thus had to be
75+
> re-flattened/re-ranked/re-compiled every time."
76+
77+
So: the re-compute-everything design is a holdover from early React Router v6 declarative
78+
mode (route trees defined at render time, so recomputing was correct then). The maintainer
79+
**explicitly endorsed patch-package as the interim approach** and is betting on the Remix 3
80+
route-pattern rewrite for the real fix. This patch is that sanctioned stopgap — and it also
81+
includes the `compilePath` cache the merged PR left on the table.
82+
83+
### Safety
84+
85+
Pure memoization of deterministic, internal-only values:
86+
87+
- `flattenRoutes`/`rankRouteBranches` and the compiled regexes depend solely on the static
88+
route manifest; the cached values are never returned to or mutated by the framework.
89+
- The compiled `RegExp` has no `/g` flag, so `.exec()` carries no cross-call state — safe to
90+
share under concurrency.
91+
- The branch cache is a `WeakMap` (collected with its route tree); the compile cache is
92+
bounded at 2000 entries (route patterns are a static set; the cap only guards any dynamic
93+
`matchPath()` use).
94+
- Targets the **CJS** build (`dist/router.cjs.js`), which the webapp server loads at runtime
95+
(`@remix-run/router` is not bundled into the server build).
96+
97+
### When to remove
98+
99+
Drop this patch if/when the webapp moves to React Router v7+ (which threads
100+
`precomputedBranches` itself) or the Remix 3 route-pattern matcher lands. Re-profile at that
101+
point — the `compilePath` cache may still be worth keeping since upstream never added it.

pnpm-lock.yaml

Lines changed: 15 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)