From de32edfb7c4c1b82688601d3924e8134aa284b83 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 9 Jun 2026 15:59:22 +0100 Subject: [PATCH] perf(webapp): memoize react-router per-request route matching via pnpm patch react-router's matchRoutes re-flattened, re-ranked, and recompiled the entire route table on every request. With the webapp's ~436 routes that cost dominates once request rates climb, and there is no NODE_ENV gate so production pays it too. Patch @remix-run/router to memoize the work that depends only on the static route manifest: cache flattened/ranked branches per route tree, hoist the loop-invariant path decode out of the match loop, and cache compiled path regexes. On a local benchmark this cut route-matching CPU by roughly 60% and halved event-loop lag under load. See patches/README.md for the upstream history (react-router#14866 / #14967) and when to drop the patch. --- .../react-router-route-matching-perf.md | 6 ++ package.json | 3 +- patches/@remix-run__router@1.23.2.patch | 69 ++++++++++++ patches/README.md | 101 ++++++++++++++++++ pnpm-lock.yaml | 27 ++--- 5 files changed, 193 insertions(+), 13 deletions(-) create mode 100644 .server-changes/react-router-route-matching-perf.md create mode 100644 patches/@remix-run__router@1.23.2.patch create mode 100644 patches/README.md diff --git a/.server-changes/react-router-route-matching-perf.md b/.server-changes/react-router-route-matching-perf.md new file mode 100644 index 00000000000..a264835af55 --- /dev/null +++ b/.server-changes/react-router-route-matching-perf.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +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. diff --git a/package.json b/package.json index 29597c36cd7..c8c89e34285 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,8 @@ "@upstash/ratelimit@1.1.3": "patches/@upstash__ratelimit.patch", "antlr4ts@0.5.0-alpha.4": "patches/antlr4ts@0.5.0-alpha.4.patch", "@window-splitter/state@1.1.3": "patches/@window-splitter__state@1.1.3.patch", - "streamdown@2.5.0": "patches/streamdown@2.5.0.patch" + "streamdown@2.5.0": "patches/streamdown@2.5.0.patch", + "@remix-run/router@1.23.2": "patches/@remix-run__router@1.23.2.patch" }, "overrides": { "typescript": "5.5.4", diff --git a/patches/@remix-run__router@1.23.2.patch b/patches/@remix-run__router@1.23.2.patch new file mode 100644 index 00000000000..7ffe58edeae --- /dev/null +++ b/patches/@remix-run__router@1.23.2.patch @@ -0,0 +1,69 @@ +diff --git a/dist/router.cjs.js b/dist/router.cjs.js +index e634d45fee327b5f9ef63eee8dc1da39b07c79d4..ce1cf6c599e7efa82d51b63d26c0c92e82931083 100644 +--- a/dist/router.cjs.js ++++ b/dist/router.cjs.js +@@ -746,6 +746,11 @@ function convertRoutesToDataRoutes(routes, mapRouteProperties, parentPath, manif + * + * @see https://reactrouter.com/v6/utils/match-routes + */ ++// trigger.dev perf patch — memoize per-request route matching. See patches/README.md ++// (backports the idea in react-router PR #14866, which was closed in favor of the partial ++// fix #14967; maintainer suggested patch-package until the Remix 3 route-pattern rewrite). ++let __branchCache = new WeakMap(); ++let __compileCache = new Map(); + function matchRoutes(routes, locationArg, basename) { + if (basename === void 0) { + basename = "/"; +@@ -758,17 +763,17 @@ function matchRoutesImpl(routes, locationArg, basename, allowPartial) { + if (pathname == null) { + return null; + } +- let branches = flattenRoutes(routes); +- rankRouteBranches(branches); ++ // flatten+rank depend only on `routes` (static) — cache per route-tree ref. ++ let branches = __branchCache.get(routes); ++ if (!branches) { ++ branches = flattenRoutes(routes); ++ rankRouteBranches(branches); ++ __branchCache.set(routes, branches); ++ } + let matches = null; ++ // decodePath(pathname) is loop-invariant — hoisted out (was recomputed per branch). ++ let decoded = decodePath(pathname); + for (let i = 0; matches == null && i < branches.length; ++i) { +- // Incoming pathnames are generally encoded from either window.location +- // or from router.navigate, but we want to match against the unencoded +- // paths in the route definitions. Memory router locations won't be +- // encoded here but there also shouldn't be anything to decode so this +- // should be a safe operation. This avoids needing matchRoutes to be +- // history-aware. +- let decoded = decodePath(pathname); + matches = matchRouteBranch(branches[i], decoded, allowPartial); + } + return matches; +@@ -1078,6 +1083,12 @@ function compilePath(path, caseSensitive, end) { + if (end === void 0) { + end = true; + } ++ // perf patch: cache the compiled [regexp, params] by pattern (see patches/README.md). ++ let __ck = path + "\0" + caseSensitive + "\0" + end; ++ let __cc = __compileCache.get(__ck); ++ if (__cc !== void 0) { ++ return __cc; ++ } + 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(/\*$/, "/*") + "\".")); + let params = []; + let regexpSource = "^" + path.replace(/\/*\*?$/, "") // Ignore trailing / and /*, we'll handle it below +@@ -1110,7 +1121,11 @@ function compilePath(path, caseSensitive, end) { + regexpSource += "(?:(?=\\/|$))"; + } else ; + let matcher = new RegExp(regexpSource, caseSensitive ? undefined : "i"); +- return [matcher, params]; ++ let __res = [matcher, params]; ++ // Bounded: route patterns are a static set; the cap guards any dynamic matchPath() use. ++ if (__compileCache.size >= 2000) __compileCache.clear(); ++ __compileCache.set(__ck, __res); ++ return __res; + } + function decodePath(value) { + try { diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 00000000000..5c6db4b9d62 --- /dev/null +++ b/patches/README.md @@ -0,0 +1,101 @@ +# Patches + +This directory holds [pnpm patches](https://pnpm.io/cli/patch) applied on install via +`pnpm.patchedDependencies` in the root `package.json`. Each `.patch` is a diff against the +published package. Most are small and self-explanatory from the diff; the non-obvious ones +are documented below. + +--- + +## `@remix-run/router@1.23.2` — route-matching memoization + +**File:** `patches/@remix-run__router@1.23.2.patch` (patches `dist/router.cjs.js`) + +### What it does + +Three changes to `matchRoutesImpl` / `compilePath`, all pure memoization of work that +depends only on the **static** route manifest: + +1. **Cache flattened + ranked branches per route-tree** (`WeakMap` keyed by the `routes` + ref). `flattenRoutes()` + `rankRouteBranches()` were recomputed on *every* `matchRoutes` + call across all ~436 webapp routes. +2. **Hoist `decodePath(pathname)` out of the branch-match loop** — it's loop-invariant but + was recomputed once per branch. +3. **Memoize `compilePath` compiled regexes** by `path|caseSensitive|end` (bounded `Map`, + cap 2000). The matcher RegExp was rebuilt on every `matchPath` call. + +### Why + +Profiling the realtime runs feed under load (100 concurrent tag feeds, ~425 req/s) found +**~68% of webapp CPU was spent in react-router's `matchRoutes`** — re-flattening, +re-ranking, and re-compiling the entire route table on every request. It is **not** a dev +artifact: there is no `NODE_ENV` gate, and a `NODE_ENV=production` profile was identical +(67.9% vs 68.3%). The realtime feed's high request rate (each long-poll returns fast and +immediately re-polls) just amplifies a latent per-request cost that large route tables pay +everywhere. + +Measured on a single instance, same load, before vs after this patch: + +| | before | after | +|---|---|---| +| active CPU (self-time / 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% | + +The realtime machinery itself (router/hydrate/serialize/diff) was ~0% — the bottleneck was +entirely generic Remix request overhead. + +### Upstream status (why we patch instead of upgrade) + +This is a known, acknowledged inefficiency, and it is **only partially fixed in React +Router v7** — which we can't adopt without a full Remix 2 → RR7 framework migration. + +- [Issue #8653 "Performance issues"](https://github.com/remix-run/react-router/issues/8653) + reported it (a user with 12k routes, ~67ms per match) and was closed as a dup of the + route-ranking discussion [remix#4786](https://github.com/remix-run/remix/discussions/4786). +- [PR #14866 "Optimize route matching performance with caching"](https://github.com/remix-run/react-router/pull/14866) + implemented *exactly this patch* (hoist `decodePath`, cache `compilePath`, cache + flatten/rank), claiming **~80% route-matching CPU reduction on a 400+ route app**. It was + **closed, not merged.** +- [PR #14967 "perf: cache flattened/ranked route branches"](https://github.com/remix-run/react-router/pull/14967) + is the partial fix that *did* ship (in v7): it caches only the branches, threaded via a + `precomputedBranches` param through the framework's server-runtime (~15% SSR gain). It + does **not** cache `compilePath` — that regex rebuild remains even on `main`. + ([PR #14971](https://github.com/remix-run/react-router/pull/14971) added client-side wins.) + +The maintainer's reasoning for closing the fuller PR (#14866), verbatim: + +> "This is great as a `patch-package` optimization for those who want it, but we are +> actively working on integrating the more performant route-pattern library from Remix 3 so +> we'd rather just do the right 'fix' and ship the new algorithm instead of trying to +> band-aide perf improvements to the existing algorithm which was written with a very +> different set of constraints. Those constraints come from early v6 when it was only +> declarative mode so route trees were defined at render time and thus had to be +> re-flattened/re-ranked/re-compiled every time." + +So: the re-compute-everything design is a holdover from early React Router v6 declarative +mode (route trees defined at render time, so recomputing was correct then). The maintainer +**explicitly endorsed patch-package as the interim approach** and is betting on the Remix 3 +route-pattern rewrite for the real fix. This patch is that sanctioned stopgap — and it also +includes the `compilePath` cache the merged PR left on the table. + +### Safety + +Pure memoization of deterministic, internal-only values: + +- `flattenRoutes`/`rankRouteBranches` and the compiled regexes depend solely on the static + route manifest; the cached values are never returned to or mutated by the framework. +- The compiled `RegExp` has no `/g` flag, so `.exec()` carries no cross-call state — safe to + share under concurrency. +- The branch cache is a `WeakMap` (collected with its route tree); the compile cache is + bounded at 2000 entries (route patterns are a static set; the cap only guards any dynamic + `matchPath()` use). +- Targets the **CJS** build (`dist/router.cjs.js`), which the webapp server loads at runtime + (`@remix-run/router` is not bundled into the server build). + +### When to remove + +Drop this patch if/when the webapp moves to React Router v7+ (which threads +`precomputedBranches` itself) or the Remix 3 route-pattern matcher lands. Re-profile at that +point — the `compilePath` cache may still be worth keeping since upstream never added it. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2b2df6bd89..782b62cf7ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ patchedDependencies: '@kubernetes/client-node@1.0.0': hash: ba1a06f46256cdb8d6faf7167246692c0de2e7cd846a9dc0f13be0137e1c3745 path: patches/@kubernetes__client-node@1.0.0.patch + '@remix-run/router@1.23.2': + hash: f14fb2af2690628a0164b66d98e8d7cd6a0e8a4a30a51e53cfb1b6caeca6f7c9 + path: patches/@remix-run__router@1.23.2.patch '@sentry/remix@9.46.0': hash: 146126b032581925294aaed63ab53ce3f5e0356a755f1763d7a9a76b9846943b path: patches/@sentry__remix@9.46.0.patch @@ -497,7 +500,7 @@ importers: version: 2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) '@remix-run/router': specifier: ^1.23.2 - version: 1.23.2 + version: 1.23.2(patch_hash=f14fb2af2690628a0164b66d98e8d7cd6a0e8a4a30a51e53cfb1b6caeca6f7c9) '@remix-run/serve': specifier: 2.17.4 version: 2.17.4(typescript@5.5.4) @@ -803,7 +806,7 @@ importers: version: 0.3.1(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4))(react@18.2.0) remix-utils: specifier: ^7.7.0 - version: 7.7.0(@remix-run/node@2.17.4(typescript@5.5.4))(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/router@1.23.2)(crypto-js@4.2.0)(intl-parse-accept-language@1.0.0)(react@18.2.0)(zod@3.25.76) + version: 7.7.0(@remix-run/node@2.17.4(typescript@5.5.4))(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/router@1.23.2(patch_hash=f14fb2af2690628a0164b66d98e8d7cd6a0e8a4a30a51e53cfb1b6caeca6f7c9))(crypto-js@4.2.0)(intl-parse-accept-language@1.0.0)(react@18.2.0)(zod@3.25.76) seedrandom: specifier: ^3.0.5 version: 3.0.5 @@ -23657,7 +23660,7 @@ snapshots: '@npmcli/package-json': 4.0.1 '@remix-run/node': 2.17.4(typescript@5.5.4) '@remix-run/react': 2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) - '@remix-run/router': 1.23.2 + '@remix-run/router': 1.23.2(patch_hash=f14fb2af2690628a0164b66d98e8d7cd6a0e8a4a30a51e53cfb1b6caeca6f7c9) '@remix-run/server-runtime': 2.17.4(typescript@5.5.4) '@types/mdx': 2.0.5 '@vanilla-extract/integration': 6.2.1(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.46.1) @@ -23772,7 +23775,7 @@ snapshots: '@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4)': dependencies: - '@remix-run/router': 1.23.2 + '@remix-run/router': 1.23.2(patch_hash=f14fb2af2690628a0164b66d98e8d7cd6a0e8a4a30a51e53cfb1b6caeca6f7c9) '@remix-run/server-runtime': 2.17.4(typescript@5.5.4) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -23782,7 +23785,7 @@ snapshots: optionalDependencies: typescript: 5.5.4 - '@remix-run/router@1.23.2': {} + '@remix-run/router@1.23.2(patch_hash=f14fb2af2690628a0164b66d98e8d7cd6a0e8a4a30a51e53cfb1b6caeca6f7c9)': {} '@remix-run/serve@2.17.4(typescript@5.5.4)': dependencies: @@ -23800,7 +23803,7 @@ snapshots: '@remix-run/server-runtime@2.17.4(typescript@5.5.4)': dependencies: - '@remix-run/router': 1.23.2 + '@remix-run/router': 1.23.2(patch_hash=f14fb2af2690628a0164b66d98e8d7cd6a0e8a4a30a51e53cfb1b6caeca6f7c9) '@types/cookie': 0.6.0 '@web3-storage/multipart-parser': 1.0.0 cookie: 0.7.2 @@ -23814,7 +23817,7 @@ snapshots: dependencies: '@remix-run/node': 2.17.4(typescript@5.5.4) '@remix-run/react': 2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) - '@remix-run/router': 1.23.2 + '@remix-run/router': 1.23.2(patch_hash=f14fb2af2690628a0164b66d98e8d7cd6a0e8a4a30a51e53cfb1b6caeca6f7c9) react: 18.2.0 react-router-dom: 6.30.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) optionalDependencies: @@ -24100,7 +24103,7 @@ snapshots: '@opentelemetry/semantic-conventions': 1.41.1 '@remix-run/node': 2.17.4(typescript@5.5.4) '@remix-run/react': 2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) - '@remix-run/router': 1.23.2 + '@remix-run/router': 1.23.2(patch_hash=f14fb2af2690628a0164b66d98e8d7cd6a0e8a4a30a51e53cfb1b6caeca6f7c9) '@remix-run/server-runtime': 2.17.4(typescript@5.5.4) '@sentry/cli': 2.50.2(encoding@0.1.13) '@sentry/core': 9.46.0 @@ -33190,14 +33193,14 @@ snapshots: react-router-dom@6.30.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: - '@remix-run/router': 1.23.2 + '@remix-run/router': 1.23.2(patch_hash=f14fb2af2690628a0164b66d98e8d7cd6a0e8a4a30a51e53cfb1b6caeca6f7c9) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) react-router: 6.30.3(react@18.2.0) react-router@6.30.3(react@18.2.0): dependencies: - '@remix-run/router': 1.23.2 + '@remix-run/router': 1.23.2(patch_hash=f14fb2af2690628a0164b66d98e8d7cd6a0e8a4a30a51e53cfb1b6caeca6f7c9) react: 18.2.0 react-smooth@4.0.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0): @@ -33585,13 +33588,13 @@ snapshots: '@remix-run/server-runtime': 2.17.4(typescript@5.5.4) react: 18.2.0 - remix-utils@7.7.0(@remix-run/node@2.17.4(typescript@5.5.4))(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/router@1.23.2)(crypto-js@4.2.0)(intl-parse-accept-language@1.0.0)(react@18.2.0)(zod@3.25.76): + remix-utils@7.7.0(@remix-run/node@2.17.4(typescript@5.5.4))(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/router@1.23.2(patch_hash=f14fb2af2690628a0164b66d98e8d7cd6a0e8a4a30a51e53cfb1b6caeca6f7c9))(crypto-js@4.2.0)(intl-parse-accept-language@1.0.0)(react@18.2.0)(zod@3.25.76): dependencies: type-fest: 4.33.0 optionalDependencies: '@remix-run/node': 2.17.4(typescript@5.5.4) '@remix-run/react': 2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4) - '@remix-run/router': 1.23.2 + '@remix-run/router': 1.23.2(patch_hash=f14fb2af2690628a0164b66d98e8d7cd6a0e8a4a30a51e53cfb1b6caeca6f7c9) crypto-js: 4.2.0 intl-parse-accept-language: 1.0.0 react: 18.2.0