Turbopack: expose `unstable_splitCssPerRule` or default it off in dev (one module per CSS rule makes `next dev` memory-bound)

Author: AlexHartfordCreated Sep 4, 2026Updated Sep 8, 2026

Suggested fix

Make per-rule CSS splitting conditional on the environment in @vanilla-extract/turbopack-plugin:

diff
   root: loaderContext.rootContext,
   identifiers,
   enableFileWatcher: false,
-  unstable_splitCssPerRule: true,
+  unstable_splitCssPerRule: process.env.NODE_ENV === 'production',

(or expose the flag through next-plugin's unstable_turbopack options so consumers can set it).

This is the same NODE_ENV check getOrMakeCompiler already uses two lines earlier to pick identifiers. I've been running exactly this as a pnpm patch — measurements below.

Why

The loader hardcodes unstable_splitCssPerRule: true, which creates roughly one virtual Turbopack module per CSS rule. For a sprinkles-based design system that is enormous, and [email protected] forwards only nextEnv, outputCss and identifiers, so it can't be tuned from userland. The flag is true in every published version (0.1.1 → 0.1.4).

Driving @vanilla-extract/[email protected] directly over two files from our design system (sprinkles.css.ts + theme.css.ts), counting cssImportSpecifier calls:

unstable_splitCssPerRule virtual CSS modules CSS emitted
true 8,676 1,347,881 B
false 3 1,356,554 B

Byte-for-byte the same CSS, ~2,900x the modules.

Impact in dev

Real app: [email protected], Pages Router, pnpm monorepo, 43 .css.ts files, ~10.8k atomic rules. Cold .next, one request to one page:

cold compile dev server peak footprint
unpatched 189 s 22 GB
patched (dev splitting off) 51 s 3.4 GB

22 GB exhausted a 32 GB machine and pushed it into swap. It is not a leak — footprint is flat across repeated recompiles (6 identical rebuilds, +0.00 GB each). It's a one-time watermark from graph size.

Incidentally, unpatched dev also served two ~4.1 MB CSS chunks that were near-identical; patched serves 2.38 MB total.

Minimal reproduction

https://github.com/AlexHartford/ve-turbopack-dev-memory-repro

bash
npm install
VE_SCALE=1200 npm run count-modules   # 10,802 modules vs 1, same CSS
VE_SCALE=1200 npm run measure         # next dev cold compile + peak footprint

VE_SCALE scales the sprinkles config; rules ≈ 9 × VE_SCALE + 2.

VE_SCALE modules cold compile peak footprint
40 362 3.1 s 644 MB
400 3,602 7.7 s 1,328 MB
1,200 10,802 19.8 s 2,153 MB

The minimal repro shows the scaling trend rather than the full 22 GB — that needs a real app's graph, where the CSS modules compound with everything else.

Production is unaffected by the fix

Per-rule splitting is what lets production tree-shake unused rules, so gating on NODE_ENV keeps it. Verified on an App Router app in the same monorepo: next build emits a byte-identical 573,706 bytes of CSS with the same selector set, patched vs unpatched (cmp clean).

Measurement note

Use physical footprint (vmmap --summary <pid> / footprint -p <pid>), not RSS. Under memory pressure macOS compresses pages, so RSS falls while real usage grows — ps reported ~6 GB for a process whose actual footprint was 19 GB with CMPRS 19G. RSS hides this entirely, which cost me a while.

Versions

[email protected], @vanilla-extract/[email protected], @vanilla-extract/[email protected], @vanilla-extract/[email protected], @vanilla-extract/[email protected], @vanilla-extract/[email protected], pnpm 10.32.0, macOS arm64, 32 GB.

Possibly related: vercel/next.js#97802 (Turbopack saturates 4 GB with a 12k-module graph), vercel/next.js#98071.

Happy to open a PR with the one-line change if that's welcome.

Source: vanilla-extract-css/vanilla-extract