# Build memory optimization campaign — 2026-06-04

Measuring peak memory of `npm run build` (community, cold `.next`) via
`scripts/build-mem.sh` in a `node:24-bookworm` container; headline =
container cgroup `memory.peak`. Host: 32 CPU / 60 GB → 31 static-gen workers.

**Rules:** must NOT decrease worker count; must NOT increase build time beyond
baseline (within variance); MAY make structural app changes. 9 rounds.

## Baseline (S0)

The config already carries prior memory work (`productionBrowserSourceMaps:false`,
`serverSourceMaps:false`, large `serverExternalPackages`, `optimizePackageImports`
tried+reverted for +20s/+480s regressions). Measured fresh:

| run | peak (MB) | dur (s) |
|---|---|---|
| base1 | 14175 | 72.5 |
| base2 | 14501 | 68.3 |
| base3 | 13619 | 68.1 |
| **median** | **14175** | **68.3** |

Noise: range ~882 MB, σ≈364 MB. **Acceptance per round: median peak (≥3 runs)
drops > ~400 MB vs prior accepted state, dur not regressed, all builds pass.**

## Rounds

| # | change | peak runs (MB) | median | Δ vs prior | dur median | verdict |
|---|---|---|---|---|---|---|
| 1 | extend serverExternalPackages (+11 server-only deps) | 13324/13314/14112 | 13324 | **−851** | 68.8s | ✅ accept |
| 2a | modularizeImports lucide-react | build FAILED | — | — | — | ❌ revert |
| 2b | staticGenerationMaxConcurrency: 4 | 14163/13473/14034 | 14034 | +710 (worse) | 67.9s | ❌ revert |

**State after R1 (S1):** median peak 13324 MB, dur 68.8s.

> **Rule change (user, mid-campaign):** worker-count reduction is now ALLOWED,
> with a new rule: wall-clock must not regress beyond normal variance
> (warm-cache builds: 66.7–72.5s, median ~68s, σ≈1.6s → bar ≲72s).

| 2 | cap static-gen/page-data workers: `experimental.cpus` default `min(4,cores)` (was host CPU count → 31 workers) | 10538/10937/11678 | **10937** | **−2387** | **58.1s** (−10.7) | ✅ accept |

**State after R2 (S2):** median peak 10937 MB, dur 58.1s. The 31-worker
static-gen pool (each loading the ~290 MB app bundle) was the dominant peak;
capping it collapses onto a **fixed ~9 GB turbopack compilation floor**
(`next build ×4`, ~2.2 GB each, independent of worker count). Build is *faster*
because over-provisioned workers added spawn/load overhead. Worker-count sweep
(1 run each): cpu16=10201, cpu8=10651, cpu4=9888, cpu2=9775 — all ~compile-floor
bound; cap=4 vs cap=8 is within noise at 3 runs.

### The post-R2 floor: a single turbopack process (~9–10 GB)

Harness cmdline capture at peak shows the floor is **one process —
`next build --turbo` at ~9.1 GB** (others are tiny postcss/npm wrappers). Its RSS
exceeds the 8 GB `--max-old-space-size`, so most of it is turbopack's **native
(Rust) compile memory**, off the V8 heap. ~2.4 GB of the cgroup peak is
reclaimable page cache (reading node_modules/source) — part of the run-to-run
noise. Levers tried against this floor — **all sub-noise or worse:**

| attempt | result vs S2 (10937 MB) | verdict |
|---|---|---|
| `NODE_OPTIONS=--max-old-space-size` 6144/4096/3072 (no OOM even at 3 GB) | 10162/10609/10221 (~−500 MB) | sub-noise; lowering prod heap headroom trades OOM safety — not baked |
| `experimental.turbopackMemoryLimit` 4/3/2 GB (no failures) | 11116/10383/10537 (~−400 MB) | sub-noise; turbopack doesn't actually shrink |
| combo (cap4 + heap6G + turbo3G), 3 runs | median 10330 (−607 MB), 55.3s | sub-noise (overlaps S2), gain is mostly the unsafe heap cap |
| **webpack** instead of turbopack | **13338 MB, 316 s** | **worse on both axes** (×5.5 slower) |
| editor/client-lib ssr:false code-split | ≤~200 MB potential (core stays server-side) | sub-noise; not attempted |

**Conclusion:** after R1+R2 (14175 → 10937 MB, −3.2 GB / −23%, *and* faster:
68→58s), the build sits on turbopack's native compilation floor for this large
app. No further **sound** change beats the ~1 GB run-to-run variance without
removing app features. Clean rounds 3–9 are not available within the rules.

### Feature-level refactors investigated (per user direction)

1. **client-lib ssr:false** (blocknote/tiptap/reactflow): the peak is turbopack
   compiling client+server bundles; these libs are compiled for the **client
   regardless** of ssr:false, which removes only the ~0.5–1% server-side compile
   (blocknote ≈ 4.7 MB of 402 MB server output). Tens of MB → unmeasurable
   against ~1 GB noise. Not pursued.

2. **dist-aliasing workspace packages** (use nx-built dist instead of turbopack
   recompiling src — the lever that *does* target the compile floor): attempted,
   hit hard blockers. All src-aliased packages have **empty dist**; `@alga-psa/ui`'s
   `tsup` build currently **fails**; its tsup config emits only enumerated index
   entries, not the per-subpath files (`dist/components/Button.mjs`) the 3488
   `@alga-psa/ui/components/*` imports require; `exports` has gaps (`presence`).
   Making it work needs: fix each package build, reconfigure tsup to emit hundreds
   of per-subpath entries (which **adds nx build time**, risking the time rule),
   fill exports gaps, and **runtime-validate** all imports (the build-only harness
   verifies compilation, not runtime resolution). A large, risky, separately-
   validated effort with uncertain turbopack-memory payoff — out of scope for a
   measure-and-iterate loop.

### Final result

| | peak (median) | dur | vs S0 |
|---|---|---|---|
| S0 baseline | 14175 MB | 68.3s | — |
| **R1+R2 (shipped)** | **10937 MB** | **58.1s** | **−3238 MB (−23%), faster** |

Two committed rounds; the build is at turbopack's compile floor.

### Learnings (narrow the search space)
- **Per-worker memory is module-graph-dominated, not render-state.** Capping
  `staticGenerationMaxConcurrency` (concurrent renders/worker) did nothing →
  the only lever is shrinking the module graph each worker loads.
- **Barrels already handled:** lucide-react/date-fns/lodash/recharts/react-icons
  are in Next 16's *default* `optimizePackageImports`. Manual `modularizeImports`
  on lucide broke on its `*Icon` aliases and was redundant anyway.
  `optimizePackageImports` itself was tried by prior work and regressed builds.
- **Common graph is already lean:** root-layout providers are light; `@alga-psa/ui`
  barrel is 16 exports and 966/1062 importers already use subpaths.
- **Worker-count knobs are off-limits or backfire:** more workers (lower
  `staticGenerationMinPagesPerWorker`) would *raise* peak (each pays the shared
  baseline); fewer is forbidden.
- Remaining real lever = code-split heavy SSR'd client libs (blocknote/tiptap/
  prosemirror editor stack — scattered ~25 community files, core stays server-side;
  reactflow/calendar are EE-heavy or few-page).
