Skip to content

Commit 28abbc9

Browse files
authored
perf(dev): re-enable the Turbopack dev filesystem cache (5.4x faster restarts) (#6151)
* perf(dev): re-enable the Turbopack dev filesystem cache (5.4x faster 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. * improvement(dev): chain the cache prune into dev scripts instead of a 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 * docs(dev): document cache-corruption recovery, the cost of enabling the 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). * refactor(dev): drop the dev-performance skill, keep its findings at the 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). * docs(dev): record the measured cost and concurrency behaviour of cache 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. * fix(dev): cap every app's Turbopack cache, not just apps/sim `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). * refactor(dev): drop dev:cache:prune in favour of the existing dev:clean `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.
1 parent 273ca06 commit 28abbc9

4 files changed

Lines changed: 155 additions & 5 deletions

File tree

apps/docs/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
"private": true,
55
"license": "Apache-2.0",
66
"scripts": {
7-
"dev": "next dev --port 3001",
7+
"dev": "bun run dev:cache:cap && next dev --port 3001",
8+
"dev:cache:cap": "bun run ../../scripts/prune-turbopack-cache.ts",
9+
"dev:clean": "rm -rf .next/dev/cache",
810
"build": "fumadocs-mdx && NODE_OPTIONS='--max-old-space-size=8192' next build",
911
"start": "next start",
1012
"postinstall": "fumadocs-mdx",

apps/sim/next.config.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,40 @@ const nextConfig: NextConfig = {
218218
],
219219
},
220220
experimental: {
221-
turbopackFileSystemCacheForDev: false,
221+
/**
222+
* Turbopack's dev filesystem cache stays ON (this is also the Next default
223+
* since v16.1). It is what makes a dev-server restart cheap: without it every
224+
* restart recompiles the route graph from scratch.
225+
*
226+
* Measured locally on `/workspace/[workspaceId]/w`, n=3 per cell, restarting
227+
* the dev server between each run:
228+
*
229+
* cache OFF 31.4s / 30.1s / 31.9s RSS ~9.0-9.8 GB
230+
* cache ON 5.7s / 6.1s / 5.7s RSS ~4.8-5.1 GB
231+
*
232+
* 5.4x faster restarts and ~1.9x less memory. Cold compile with an empty
233+
* cache is unchanged (~32s either way) — the cache only pays back on restart.
234+
*
235+
* This is deliberately NOT the same decision as `turbopackFileSystemCacheForBuild`
236+
* below. That one is measured-harmful for `next build`; this one is
237+
* measured-beneficial for `next dev`. It was previously `false`, but that was
238+
* incidental — it was introduced by a landing-page redesign (#5408) whose
239+
* description never mentions Turbopack, caching, or dev performance, and it
240+
* is not covered by the #6078 build A/B cited below.
241+
*
242+
* The cache is unbounded on disk (an abandoned one reached 78 GB here), so
243+
* `scripts/prune-turbopack-cache.ts` is chained into every `dev` script to cap it.
244+
* A *corrupted* cache can abort Turbopack outright ("Cache corruption
245+
* detected: checksum mismatch") rather than falling back — it depends whether
246+
* the damaged region is read. `bun run dev:clean` and restart is the fix.
247+
*
248+
* If you re-measure any of this: `next dev` compiles routes on demand, so
249+
* startup time means nothing — time the first request to a route, restart the
250+
* server between runs, and stop it with SIGINT. A `kill -9` mid-write makes
251+
* Turbopack discard the partially-written cache and rebuild silently, which
252+
* reads as "the cache does nothing" and is how this flag stayed wrong.
253+
*/
254+
turbopackFileSystemCacheForDev: true,
222255
/**
223256
* Turbopack's persistent build cache (beta) stays off — it is a net loss at
224257
* this app's size. A controlled A/B on a byte-identical module graph (PR

apps/sim/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
"node": ">=22.19.0"
99
},
1010
"scripts": {
11-
"dev": "next dev --port 3000",
12-
"dev:minimal": "SIM_DEV_MINIMAL_REGISTRY=1 next dev --port 3000",
13-
"dev:capped": "NODE_OPTIONS='--max-old-space-size=4096' next dev --port 3000",
11+
"dev": "bun run dev:cache:cap && next dev --port 3000",
12+
"dev:minimal": "bun run dev:cache:cap && SIM_DEV_MINIMAL_REGISTRY=1 next dev --port 3000",
13+
"dev:capped": "bun run dev:cache:cap && NODE_OPTIONS='--max-old-space-size=4096' next dev --port 3000",
14+
"dev:cache:cap": "bun run ../../scripts/prune-turbopack-cache.ts",
1415
"dev:clean": "rm -rf .next/dev/cache",
1516
"dev:webpack": "next dev --webpack",
1617
"load:workflow": "bun run load:workflow:baseline",

scripts/prune-turbopack-cache.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Caps the size of Turbopack's dev filesystem cache.
4+
*
5+
* `experimental.turbopackFileSystemCacheForDev` is ON (see `apps/sim/next.config.ts`)
6+
* because it makes dev-server restarts 5.4x faster and cuts RSS ~1.9x. The tradeoff
7+
* is that the cache is an append-heavy LSM store with no upstream GC: it grows with
8+
* every route compiled and every code change, and it is never pruned. An abandoned
9+
* cache in this repo reached 78 GB across 1,848 SST files.
10+
*
11+
* A stale cache is also slower to read back, so an unbounded one eventually erodes
12+
* the win it exists to provide. This script drops the whole cache once it crosses a
13+
* threshold; Turbopack rebuilds it on the next run (one cold compile, ~32s).
14+
*
15+
* Also the remedy when Turbopack aborts with `Cache corruption detected:
16+
* checksum mismatch` — it does not self-heal from a corrupted cache, so prune
17+
* and restart. (An ordinary hard kill does not corrupt it; Turbopack discards a
18+
* partial write and rebuilds silently.)
19+
*
20+
* Runs automatically before every `dev` script. Also invokable directly:
21+
* bun run scripts/prune-turbopack-cache.ts # prune if over the cap
22+
* bun run scripts/prune-turbopack-cache.ts --force # always prune
23+
* bun run scripts/prune-turbopack-cache.ts --dry-run # report only
24+
*
25+
* Override the cap with `SIM_TURBOPACK_CACHE_MAX_GB` (default 20).
26+
*
27+
* The cap is a backstop, not routine maintenance. A normal session's cache sits
28+
* around 1-2 GB; the 78 GB case was a month of accumulation while the feature was
29+
* effectively abandoned. Measured cost of the size walk: ~30ms on a real cache,
30+
* ~85ms at 2,000 files — under 2% of a warm restart.
31+
*
32+
* Safe to run while a dev server is live, which happens if a second server is
33+
* started from the same checkout: the running server keeps its in-memory state
34+
* and continues serving (verified — no crash, no corruption). It does stop
35+
* persisting for the rest of that session, so its *next* start is cold once, then
36+
* back to normal. Nothing to clean up.
37+
*
38+
* Note for anyone benchmarking the cache: stop the dev server with SIGINT, not
39+
* `kill -9`. Turbopack discards a partially-written cache and rebuilds silently,
40+
* so a hard kill makes a real cache win read as no win at all.
41+
*/
42+
import { existsSync, statSync } from 'node:fs'
43+
import { readdir, rm, stat } from 'node:fs/promises'
44+
import path from 'node:path'
45+
46+
const DEFAULT_MAX_GB = 20
47+
const BYTES_PER_GB = 1024 ** 3
48+
49+
/**
50+
* Resolved from the working directory, not hardcoded to one app: every Next app
51+
* in the monorepo has its own dev cache (`apps/docs` runs `next dev` too and, with
52+
* no override, uses the Next default where the cache is on). Each app caps its
53+
* own, so one app's dev start never prunes a cache another app is holding open.
54+
*/
55+
const CACHE_DIR = path.resolve(process.cwd(), '.next', 'dev', 'cache', 'turbopack')
56+
57+
/** Sums the size of every regular file under `dir`, skipping symlinks. */
58+
async function directorySize(dir: string): Promise<number> {
59+
let total = 0
60+
const entries = await readdir(dir, { withFileTypes: true, recursive: true })
61+
for (const entry of entries) {
62+
if (!entry.isFile()) continue
63+
try {
64+
total += (await stat(path.join(entry.parentPath, entry.name))).size
65+
} catch {
66+
// Turbopack compacts while we walk; a file vanishing mid-scan is expected.
67+
}
68+
}
69+
return total
70+
}
71+
72+
function parseMaxGb(): number {
73+
const raw = process.env.SIM_TURBOPACK_CACHE_MAX_GB
74+
if (!raw) return DEFAULT_MAX_GB
75+
const parsed = Number(raw)
76+
if (!Number.isFinite(parsed) || parsed <= 0) {
77+
console.warn(
78+
`[turbopack-cache] ignoring invalid SIM_TURBOPACK_CACHE_MAX_GB=${raw}, using ${DEFAULT_MAX_GB}`
79+
)
80+
return DEFAULT_MAX_GB
81+
}
82+
return parsed
83+
}
84+
85+
async function main() {
86+
const force = process.argv.includes('--force')
87+
const dryRun = process.argv.includes('--dry-run')
88+
89+
if (!existsSync(CACHE_DIR) || !statSync(CACHE_DIR).isDirectory()) return
90+
91+
const maxGb = parseMaxGb()
92+
const bytes = await directorySize(CACHE_DIR)
93+
const gb = bytes / BYTES_PER_GB
94+
const sizeLabel = `${gb.toFixed(1)} GB`
95+
96+
if (!force && gb <= maxGb) {
97+
if (dryRun) console.log(`[turbopack-cache] ${sizeLabel} (cap ${maxGb} GB) — keeping`)
98+
return
99+
}
100+
101+
const reason = force ? 'forced' : `over the ${maxGb} GB cap`
102+
if (dryRun) {
103+
console.log(`[turbopack-cache] ${sizeLabel} is ${reason} — would prune (dry run)`)
104+
return
105+
}
106+
107+
console.log(`[turbopack-cache] ${sizeLabel} is ${reason} — pruning; next start recompiles cold`)
108+
await rm(CACHE_DIR, { recursive: true, force: true })
109+
}
110+
111+
main().catch((error) => {
112+
// Never block `next dev` on a cache-maintenance failure.
113+
console.warn('[turbopack-cache] prune skipped:', error)
114+
})

0 commit comments

Comments
 (0)