Skip to content

perf(routing): cache app-route-graph directory reads — 5000-route build −32%#2389

Merged
james-elicx merged 1 commit into
cloudflare:mainfrom
hyf0:perf/route-graph-dir-cache
Jun 28, 2026
Merged

perf(routing): cache app-route-graph directory reads — 5000-route build −32%#2389
james-elicx merged 1 commit into
cloudflare:mainfrom
hyf0:perf/route-graph-dir-cache

Conversation

@hyf0

@hyf0 hyf0 commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

No dependencies — reviewable and mergeable on its own. Touches only routing/app-route-graph.ts.

What

Memoizes the raw fs.readdirSync(dir, { withFileTypes: true }) calls in the App Router graph scan, via a per-scan WeakMap<ValidFileMatcher, Map<string, Dirent[]>> (the same pattern as the existing findFileProbeCache / findSlotSubPagesCache). discoverParallelSlots and findSlotRootPage read through it.

Why

discoverInheritedParallelSlots reads every ancestor directory of each route to look for @slot dirs; because routes share ancestors, a directory with N sibling routes is re-read once per descendant route — super-linear in route-directory width. findFileProbeCache memoizes findFile, but not these raw readdirSync/existsSync calls. The graph is scanned both during build and at dev-server startup.

Measured

Before/after this cache on the generated stress app (#2388 adds the 5000-route scenario), local wall-clock. The effect is super-linear, so it grows with scale:

Scale Metric Baseline With cache Change
5,000 routes Production build ~30 s ~20 s −32%
5,000 routes Dev-server cold start ~29 s ~10 s −67%
10,000 routes Production build ~86 s ~42 s −52%
10,000 routes Dev-server cold start ~101 s ~17 s −83%

readDirEntriesCached returns exactly the entries fs.readdirSync would for each directory, so the discovered route/file set — and the module transforms that follow — are unchanged (verified by an identical module-transform count). Dev cold start improves because the route scan runs synchronously at every dev (re)start, independent of the .vite cache — so this isn't only a cold-cache effect.

At scale vs Next.js (Turbopack)

With this cache in place, vinext's build-time lead over Next.js (Turbopack) widens sharply with route count — same machine, same generated app, clean production builds (median of 3, local wall-clock):

Scale vinext Next.js (Turbopack) vinext faster
33 routes 2.6 s 5.1 s 1.95×
5,033 routes 20.2 s 106.0 s 5.26×

Marginal cost per added route ≈ 3.5 ms (vinext) vs 20 ms (Turbopack). Keeping the route-graph scan near-linear (this PR) is part of why vinext holds its lead as apps grow.

Memory safety

The WeakMap is keyed on the per-scan matcher clone (like findSlotSubPagesCache), so entries are released when the scan's matcher is GC'd — nothing accumulates across rebuilds/restarts in a long-lived dev server (a module-level Map would leak here; the WeakMap-per-scan-matcher does not).

Correctness

  • readDirEntriesCached caches [] only for a missing directory (ENOENT/ENOTDIR) — exactly replacing the prior fs.existsSync(dir) guard — and rethrows any real fault (EACCES, EMFILE/ENFILE, …), matching the original unguarded readdirSync. A genuine FS error still fails the build loudly rather than silently caching an empty listing and dropping slots. The cache returns exactly the entries readdirSync would, so the discovered file set and resulting transforms are identical (same module-transform count).
  • Route/slot suites pass — app-route-graph, slot, app-optimistic-routing, routing, route-sorting, app-rsc-route-matching, hybrid-route-priority, app-page-route-wiring, pages-router, intercepting-routes-build, app-browser-interception-context: 662 tests. vp lint clean.

@hyf0 hyf0 marked this pull request as ready for review June 27, 2026 19:13
@hyf0 hyf0 changed the title perf(routing): memoize directory reads during app route graph scan perf(routing): cache app-route-graph directory reads — 10k routes: build −52%, dev cold start −83% Jun 28, 2026
@hyf0 hyf0 force-pushed the perf/route-graph-dir-cache branch from a28c9ce to 5d668ca Compare June 28, 2026 04:25
@hyf0 hyf0 changed the title perf(routing): cache app-route-graph directory reads — 10k routes: build −52%, dev cold start −83% perf(routing): cache app-route-graph directory reads — 5000-route build −32% Jun 28, 2026
@hyf0 hyf0 force-pushed the perf/route-graph-dir-cache branch 2 times, most recently from aa3984b to 7ac72a9 Compare June 28, 2026 06:35
@pkg-pr-new

pkg-pr-new Bot commented Jun 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2389
npm i https://pkg.pr.new/vinext@2389

commit: d64ac0b

discoverInheritedParallelSlots reads every ancestor directory of each route with fs.readdirSync to find @slot dirs. Because routes share ancestors, a directory with N sibling routes is read once per descendant route, making the scan super-linear in route-directory width. Memoize the reads in a per-scan WeakMap<matcher, Map<dir, Dirent[]>> (same pattern as findFileProbeCache/findSlotSubPagesCache), scoped to one scan so it's collected afterwards. On a 10k-route fixture this cuts build wall time ~52% (~86s -> ~42s) and syscall time ~61%, output byte-identical.
@github-actions

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared d64ac0b against base da086c0 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 122.0 KB 122.0 KB ⚫ +0.0%
Client entry size (gzip) vinext 117.0 KB 117.0 KB ⚫ +0.0%
Dev server cold start vinext 2.46 s 2.48 s ⚫ +0.8%
Production build time vinext 2.94 s 2.94 s ⚫ -0.2%
RSC entry closure size (gzip) vinext 94.3 KB 94.3 KB ⚫ +0.0%
Server bundle size (gzip) vinext 160.0 KB 160.0 KB ⚫ +0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx james-elicx merged commit ae43973 into cloudflare:main Jun 28, 2026
48 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants