perf(dev): re-enable the Turbopack dev filesystem cache (5.4x faster restarts) - #6151
Conversation
…restarts) `turbopackFileSystemCacheForDev` has been `false` since #5408 — a landing-page homepage redesign whose description covers hero cards, feature-card aspect ratios, eyebrow chips and a voice-input button color, and never mentions Turbopack, caching, or dev performance. It was collateral, not a decision, and it overrode the Next default (true since v16.1). It is not the flag #6078/#6080 measured. That A/B was `...ForBuild` and its conclusion stands — the build cache is a 3.2x regression and stays off. The two flags look alike and are opposite decisions; both are now commented as such. Measured on `/workspace/[workspaceId]/w`, n=3 per arm, SIGINT between runs: cache OFF 31.4s / 30.1s / 31.9s RSS 9.0-9.8 GB cache ON 5.6s / 5.6s / 5.5s RSS 4.4-5.1 GB 5.4x faster restarts, ~2x less resident memory. Cold compile against an empty cache is unchanged (~32s either way) — the cache only pays back on restart, which is the loop that actually hurts. The cache is unbounded on disk: the abandoned one on this machine had reached 78 GB across 1,848 SST files, and a stale cache is slower to read back, so left alone it erodes the win it exists to provide. `prune-turbopack-cache.ts` runs on `predev` and drops it past a cap (default 20 GB, `SIM_TURBOPACK_CACHE_MAX_GB` to override); `bun run dev:cache:prune` forces it. It never blocks `next dev` on a maintenance failure. Adds a `dev-performance` skill recording the cost model, the reference numbers, and the benchmarking method — including that stopping the server with `kill -9` mid-cache-write discards the cache and makes this exact win read as no win.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
PR SummaryMedium Risk Overview Adds Documents the dev cost model, benchmark method (SIGINT not Reviewed by Cursor Bugbot for commit 4aa07e5. Configure here. |
Greptile SummaryThis PR re-enables Turbopack's persistent development cache and adds automatic size-based pruning plus development-performance guidance. The pruning integration misses the documented root-level app-only startup path.
Confidence Score: 4/5The root-level app-only development path must be wired to prune the newly enabled cache before this PR is safe to merge. The cache is explicitly unbounded, but the documented root Files Needing Attention: apps/sim/package.json and the root Turbo dev orchestration
|
| Filename | Overview |
|---|---|
| apps/sim/next.config.ts | Re-enables the development filesystem cache while preserving the independently disabled build cache. |
| scripts/prune-turbopack-cache.ts | Implements configurable cache-size measurement and nonblocking recursive pruning at the expected Next development-cache path. |
| apps/sim/package.json | Adds pruning hooks for direct package commands, but the root Turbo-based app-only command bypasses them. |
| .agents/skills/dev-performance/SKILL.md | Documents the development cost model, cache decisions, and benchmarking procedure. |
| .claude/commands/dev-performance.md | Mirrors the development-performance guidance for Claude commands. |
| .cursor/commands/dev-performance.md | Mirrors the development-performance guidance for Cursor commands. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
A[Developer runs root bun run dev] --> B[turbo run dev]
B --> C[apps/sim dev task]
C --> D[next dev]
B -. lifecycle hook skipped .-> E[predev prune script]
D --> F[Unbounded Turbopack cache remains uncapped]
Reviews (1): Last reviewed commit: "perf(dev): re-enable the Turbopack dev f..." | Re-trigger Greptile
Stack overviewThis is the base of a five-PR stack. Combined, measured end-to-end on the canvas route (n=3 per arm, 14-core / 48 GB Mac):
The two halves are independent and compose: #6151 makes restarts cheap, #6155 makes the graph small. Either is worth landing alone. Behaviour neutrality: full suite 17,277 passed, plus a 4,404-probe differential comparing old registry reads against new metadata reads across every tool (resolve / existence / params / outputs / name) — 0 mismatches. |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 4aa07e5. Configure here.
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 4aa07e5. Configure here.
… predev hook Review read the root `bun run dev` path as bypassing the `predev` hook and so never capping the newly-enabled cache. Turbo does fire `pre*` hooks — verified live, the run prints the prune before `next dev` — but the concern is fair in that the guarantee rested on package-manager lifecycle semantics that are invisible at the call site. Chaining it explicitly removes the question entirely: every `dev` variant now runs `bun run dev:cache:cap && …`, which holds on any invocation path, is visible in the command itself, and drops the three duplicated `predev:*` entries for one shared script. Verified on both paths — direct `bun run dev` and root `turbo run dev`, the latter printing: sim:dev: $ bun run dev:cache:cap && next dev --port 3000 sim:dev: $ bun run ../../scripts/prune-turbopack-cache.ts
Note on the 4/5 score aboveThat review ran at ~06:00 UTC against an earlier head; the branch is now at a later commit and Greptile has not re-run despite repeated triggers (it does not appear to re-review on force-push for these stacked bases). Flagging it so the stale score isn't read as an open concern. Its stated reason — the The full Test and Build workflow was dispatched against the stack head and passed. Note that |
…he cache Stress-tested the failure mode rather than assuming it: deliberately corrupting an SST block makes Turbopack abort with a FATAL panic — it does not self-heal. FATAL: An unexpected Turbopack error occurred. Cache corruption detected: checksum mismatch in block 4 of 00000221.sst `bun run dev:cache:prune` and restart fixes it; verified the canvas serves 200 again afterwards. Documented in the skill and in the script's header, since the symptom is a hard crash and the remedy is not guessable. This is the honest cost of turning the cache on. It is worth paying — a 5.4x faster restart against a rare, loud, single-command failure — but it should be written down rather than discovered. Worth distinguishing from the adjacent case: an ordinary hard kill does *not* corrupt the cache. Turbopack discards a partially-written cache and rebuilds it silently, which is exactly why a `kill -9`-based benchmark reads as "no cache win" (noted in the benchmarking section).
|
| staging | full stack | |
|---|---|---|
| cold (first ever) | 31.3 s / 11.43 GB | 21.7 s / 10.19 GB |
| restart 1 | 29.7 s / 11.50 GB | 4.2 s / 4.15 GB |
| restart 2 | 31.2 s / 11.46 GB | 4.2 s / 4.10 GB |
| restart 3 | 30.4 s / 11.44 GB | 4.1 s / 4.06 GB |
7.3× faster (30.4 s → 4.2 s avg) and 2.8× less memory (11.46 GB → 4.10 GB), on every restart after the first. Realtime came up in every run; the canvas returned 200 every time.
The app itself is faster too, not just compilation
Warm server (fully compiled before sampling), median of 9 requests per route:
| route | scripts before → after | HTML before → after | median TTFB |
|---|---|---|---|
/w |
206 → 138 (−33%) | 1151 → 1045 KB | 123 → 108 ms |
/logs |
190 → 122 (−36%) | 345 → 231 KB (−33%) | 109 → 93 ms |
/tables |
170 → 102 (−40%) | 336 → 222 KB (−34%) | 127 → 109 ms |
/files |
200 → 132 (−34%) | 349 → 235 KB (−33%) | 145 → 128 ms |
33–40% fewer script requests per page load — 68 fewer on the canvas — and a third less HTML, because the client-reference manifest shrank with the module graph. Alongside the −9.2% gzipped production bundle, this is a user-facing win, not only a developer one.
…he code A whole skill was too much for what this is. The parts that are load-bearing — why the two lookalike cache flags are opposite decisions, the measured numbers, the corruption remedy, and the benchmarking trap — now live in the config and script they describe, where someone changing the flag actually reads them. The trap is the piece worth keeping: `next dev` compiles on demand so startup time is meaningless, and stopping the server with `kill -9` makes Turbopack discard a partially-written cache and rebuild silently — which reads as 'the cache does nothing' and is how this flag stayed wrong for a month. Dropped rather than relocated: generic advice that was not specific to this repo (antivirus, Docker-on-macOS, orphaned processes) and a measured no-op (`optimizePackageImports` for lucide-react changed nothing, 31.6s vs 31.7s).
…e pruning Stress-tested the maintenance path rather than assuming it is free. Cost: the size walk is ~30ms on a real cache and ~85ms at 2,000 files — under 2% of a 4.2s warm restart, and invisible against a cold one. It runs before every dev start, so it needed to be cheap; it is. Concurrency: pruning while a dev server is live (which happens when a second server is started from the same checkout) does not crash it. The running server keeps its in-memory state and kept serving HTTP 200 with zero panics. It does stop persisting for the rest of that session, so its next start is cold once — verified recovering at 23.4s then 4.5s. Worth writing down because the directory silently never reappears mid-session, which looks like a bug if you go looking. The cap is a backstop, not routine: a normal session sits at 1-2 GB against a 20 GB default.
`apps/docs` is a Next app too (`next dev --port 3001`) and overrides nothing, so it uses the Next default where the dev filesystem cache is on. It already had an uncapped 1.1 GB cache here, and the root `bun run dev` (`turbo run dev`) starts it — so a teammate using the documented command was accumulating a cache nothing would ever prune. The script now resolves its target from the working directory instead of hardcoding `apps/sim`, and each app chains its own cap. Per-app rather than one sweep on purpose: a single pass would let one app's dev start delete a cache another app is holding open, which costs that session its persistence. Verified both: `apps/sim` and `apps/docs` each report and cap their own 1.1 GB cache, and both dev servers start clean (`Ready in 299ms` / `229ms`, docs serving).
`dev:cache:prune` duplicated `dev:clean`, which already existed in `apps/sim` and does strictly more (`rm -rf .next/dev/cache` covers the Turbopack cache plus the fetch and image caches). Two commands for one job is worse than one, and the docs pointed at the newer, narrower of the two. Removes it from both apps and gives `apps/docs` the `dev:clean` that `apps/sim` already had, so the recovery command is the same everywhere. `dev:cache:cap` stays — it is the chained step, used by more than one dev variant, and naming it keeps the relative script path out of each command. Verified `dev:clean` is a real remedy: corrupt a cache block, run it, restart — canvas serves 200 with no panic. Also corrects an overstatement. A damaged cache does not *always* abort Turbopack; whether it panics depends on whether the damaged region is read, so it is not reliably reproducible. Both notes now say "can abort" and give the same remedy either way.
Why
experimental.turbopackFileSystemCacheForDevhas beenfalsesince #5408 — a landing-page homepage redesign. That PR's description covers hero cards, feature-card aspect ratios, eyebrow chips and a voice-input button color. It never mentions Turbopack, caching, or dev performance. It was collateral, and it overrode the Next default (truesince v16.1).It is not the flag #6078/#6080 measured. That A/B was
turbopackFileSystemCacheForBuild, and its conclusion stands — the build cache is a 3.2x regression and stays off in this PR. The two flags look alike and are opposite decisions; both now carry comments saying so, so neither gets flipped from the other's evidence.Forensic corroboration: the abandoned cache on my machine last wrote
Jul 7 17:51; #5408 was authoredJul 7 18:02 PDT.Measurement
/workspace/[workspaceId]/w(the canvas), n=3 per arm, dev server stopped with SIGINT between every run:5.4x faster restarts, ~2x less resident memory. Ranges do not overlap.
Cold compile against an empty cache is unchanged (~32s either way) — the cache only pays back on restart, which is the loop that actually hurts.
Cache growth
The dev cache is unbounded on disk. The abandoned one here had reached 78 GB across 1,848 SST files, and a stale cache is slower to read back, so left alone it erodes the win it exists to provide.
scripts/prune-turbopack-cache.tsruns onpredev(andpredev:minimal/predev:capped) and drops the cache once it crosses a cap — default 20 GB,SIM_TURBOPACK_CACHE_MAX_GBto override.bun run dev:cache:pruneforces it. It never blocksnext devon a maintenance failure.Verified: keeps under cap, prunes over cap,
--dry-run,--force, invalid-env fallback, and that Bun firespre*hooks.Skill
Adds
.agents/skills/dev-performancerecording the cost model (next devcompiles per route on demand — startup time is meaningless), reference numbers, the two opposite cache flags, and the benchmarking method.Including one trap worth reading: stopping the dev server with
kill -9mid-cache-write discards the cache, which makes this exact win read as no win. It produced a false negative during the investigation.Test plan
bun run skills:checkin sync