docs(findings): full peak breakdown, the preloading correction, and jitless rejected

Records the Phase 2 peak work:

  - Corrects the earlier claim that Next preloading all 74 route graphs is
    'framework behaviour, not something this codebase sets'. Next documents
    experimental.preloadEntriesOnStart for exactly this, found by reading its
    memory-usage guide rather than by inference. Includes the deterministic
    per-stage table: 310 files and 6.3 MiB of source stay unloaded even after a
    full multi-tab session, so the cost is not deferred, it is never paid.
  - Rejects --jitless with evidence. Code space really does go 11.6 -> 0.0 MiB and
    peak RSS looked 24% better, but the flag was applied to the server while the
    browser's listener count fell 80% and board load got faster, which is
    impossible. The page under-rendered; the win was an app doing less work. The
    tell generalises: a server-side change moving a client-side metric means the
    workload changed and the comparison is void.
  - Notes that --optimize-for-size, --no-opt and --lite-mode are rejected outright
    by Node in NODE_OPTIONS, and that the rest of Next's memory guide covers
    build-time memory under Webpack, which does not apply to this Turbopack build.
This commit is contained in:
Thomas Camlong committed 2026-08-10 09:18:55 +02:00
1 parent bcea4733b9
commit e687c1dd22
1 file changed
+63 -6
+63 -6
View File
@@ -772,8 +772,8 @@ by `scripts/benchmarks/analyze-require-graph.mjs`). At boot idle, having served
Two things follow, and the second is the one that matters:
1. **Next loads all 74 app route bundles at startup**, not on first request. That is
framework behaviour, not something this codebase sets.
1. **Next loads all 74 app route bundles at startup**, not on first request. ~~That is
framework behaviour, not something this codebase sets.~~ **That was wrong** — see below.
2. **One subtree of 16.10 MiB / 460 files is reached by the tRPC/API routes *and* by
`instrumentation`** — the cron tasks and websocket server. Exclusive attribution for
instrumentation is therefore **0 bytes**: everything it touches, an API route touches
@@ -782,10 +782,67 @@ Two things follow, and the second is the one that matters:
request handler.
That kills the obvious fix. Deferring `instrumentation` would free nothing, because Next
preloads `/api/[...trpc]` at boot and that route reaches the same graph. Cutting this
requires the tRPC router to stop referencing every integration eagerly — the same lever the
integrations barrel diet started, with roughly 16 MiB of source still behind it. Recorded
as measured and understood, not attempted here.
preloads `/api/[...trpc]` at boot and that route reaches the same graph. Cutting the shared
subtree itself requires the tRPC router to stop referencing every integration eagerly — the same
lever the integrations barrel diet started, with roughly 16 MiB of source still behind it.
### Correction: preloading *is* configurable, and turning it off is the largest idle win here
The claim above that route preloading is "framework behaviour, not something this codebase sets"
was wrong. Next documents `experimental.preloadEntriesOnStart: false` for exactly this, and the
guide describes it as preventing the server from preloading page modules into memory at startup.
Found by reading Next's own memory-usage guide rather than by inference — which is the lesson.
Deterministic per-stage measurements, identical across both runs on each side:
| stage | source loaded | files | heapUsed |
| --- | --- | --- | --- |
| boot idle | 24.0 → **10.6 MiB** | 1114 → **706** | 70.7 → **44.8 MiB** (−37%) |
| post-login | 27.9 → **19.3 MiB** | 1215 → **884** | 123.8 → **109.8 MiB** |
| board loaded | 29.0 → **21.7 MiB** | 1323 → **1006** | run-to-run noise |
| multi-tab | 29.2 → **22.9 MiB** | 1325 → **1015** | 139.7 → **127.8 MiB** |
| post-soak | 29.2 → **22.9 MiB** | 1325 → **1015** | 142.5 → **127.4 MiB** |
The last two rows are the important ones: **310 files and 6.3 MiB of source stay unloaded even
after a full multi-tab session**, so this is not merely deferred cost — routes a session never
visits are never paid for at all. Live heap is lower at every stage.
Peak node RSS does not move (350.3 → 346.1, spreads overlap), for the same reason the ssh2 chop
did not move it: V8 sizes its heap against available headroom, so a smaller live set does not
shrink peak RSS. Median board load is +3% with overlapping spreads, so the first-hit cost this
trades for did not show at this workload. Full E2E suite passes 8/8.
Also from that guide, and **not** applicable here: `webpackMemoryOptimizations`,
`serverSourceMaps`, `enablePrerenderSourceMaps` and disabling the Webpack cache all address
*build-time* memory, and this build uses Turbopack.
### `--jitless`: rejected, and the reason generalises
V8's `--jitless` removes the optimising compiler and all generated machine code. It applies
through `NODE_OPTIONS` and its effect on code memory is real and unambiguous: **code space
11.6 → 0.0 MiB**. The stress figures looked spectacular — peak node RSS −24%, peak container
−36%, CPU −14%, median board load 30% *faster*.
Three of those are impossible. The flag was applied to the **server**, so it cannot make the
browser lighter, and removing an optimising compiler does not make code faster. The client
metrics show what actually happened:
| | normal | jitless |
| --- | --- | --- |
| DOM nodes | 8,256 / 8,280 | **5,442 / 5,442** |
| JS event listeners | 7,781 / 7,784 | **1,570 / 1,570** |
| browser JS heap | 50.0 / 49.5 MiB | 28.1 / 28.2 MiB |
| widget controls available | 244 | 216 |
Boards still reported 28/28 widgets ready, so the coarse readiness check passed — but the page
carries **80% fewer event listeners**. Much of the UI never mounted. The memory "win" is an app
doing less work, and the CPU and latency "improvements" are the same artefact.
The generalisable tell: **when a server-side change moves a client-side metric, the workload
changed and the comparison is void.**
Flags that cannot be used this way at all: `--optimize-for-size`, `--no-opt` and `--lite-mode`
are rejected by Node with "not allowed in NODE_OPTIONS".
## Measured but not pursued