perf: externalise @ioredis/commands, and identify what actually holds the memory

Tested the hypothesis that cached base64 images were bloating TanStack Query and
Redis. They are not, measured on a loaded 4-tab session:

  base64 data-URLs, server heap   0.01 MiB (2 strings)
  base64 blobs, browser heap      0.01 MiB (7 strings)
  Redis total                     2.17 MB
  image bytes in Redis            none - image-proxy stores URL + headers only

What the server heap is actually made of (141 MiB used): 41.4 MiB is bundled
JavaScript source retained as strings, ~29% of the heap. The memory is the code, not
cached data — which is why every cache-tuning idea in the fix catalog measured at
zero, and why serverExternalPackages was the only lever that moved idle memory.

7.86 MiB of that is literal duplicate copies: the same module inlined into several
Turbopack chunks and retained once per chunk. @ioredis/commands' command table was in
10 chunks and resident 6 times (2.07 MiB wasted), so it is now external — ioredis is
already external and owns it as a direct dependency, so it stays traced.

Recorded as bundle hygiene, NOT a memory win: idle 89.7 -> 89.4 MiB is inside noise,
and board-loaded/peak deltas were smaller than the run-to-run spread (one "before" run
read 220.6 MiB against 169-173 for identical code).

got and @octokit/request-error duplicate just as badly but were deliberately left
bundled: they are only transitive dependencies, and a check inside the image showed
got is not traced into node_modules, so externalising it would have turned a bundled
module into a require that resolves to nothing — a possible MODULE_NOT_FOUND for zero
measured gain.

analyze-heap-strings.mjs buckets a snapshot's strings by what they are, which is what
made "37 MiB of strings" actionable instead of just alarming.

Verified: 28/28 widgets render, auth control passes, zero module-not-found in the
container log, typecheck clean. pnpm test 1794/1795 with two different Testcontainers
flakes across runs (pi-hole, sabnzbd); both pass 18/18 in isolation.
This commit is contained in:
Thomas Camlong committed 2026-08-09 16:07:26 +02:00
1 parent 933a54ead9
commit f707f64333
3 files changed
+129

No files matched your search

+55
View File
@@ -301,8 +301,63 @@ is a correctness fix — idle commits stayed within their 73–96 range afterwar
the app-root churn dominates and this hook is not what drives it. Recorded as *not* a
measured win.
## What is actually hogging the memory
Tested directly against the hypothesis that cached base64 images were bloating
TanStack Query / Redis. **They are not** — measured on a loaded 4-tab session:
| suspect | measured | verdict |
| --- | --- | --- |
| base64 data-URLs in server heap | **0.01 MiB** (2 strings) | not a factor |
| base64 blobs in browser heap | **0.01 MiB** (7 strings) | not a factor |
| Redis total | **2.17 MB** | not a factor |
| image bytes in Redis | **none** — `image-proxy` stores only URL + headers | not a factor |
| React Query `gcTime` | already 5 min, not 24 h | already addressed |
What the server heap is actually made of (141 MiB used, 259 MiB RSS):
| bucket | size | share of heap |
| --- | --- | --- |
| **bundled JS source retained as strings** | **41.4 MiB** | **~29%** |
| other strings (translations, ids, misc) | 10.3 MiB | 7% |
| URLs | 2.0 MiB | 1% |
| everything else (objects, code, maps) | ~87 MiB | 62% |
**The memory is the code, not cached data.** That is why every cache-tuning idea in the
fix catalog measured at zero, and why `serverExternalPackages` — which removes code from
the boot-loaded bundle — was the only lever that moved idle memory.
Within that, **7.86 MiB is literal duplicate copies** of identical chunk sources: the same
module inlined into several Turbopack chunks and retained once per chunk. Worst offenders
were `@ioredis/commands`' command table (in 10 chunks, resident 6×, 2.07 MiB wasted) and
`got`/`@octokit/request-error` (2.1 MiB each).
`@ioredis/commands` is now external, which removes it from all 10 chunks. Recorded as
**bundle hygiene, not a memory win**: idle went 89.7 → 89.4 MiB, inside noise, and
board-loaded/peak differences were smaller than the run-to-run spread (one "before" run
read 220.6 MiB against 169–173 for identical code).
`got` and `@octokit/request-error` were deliberately **not** externalised. They are only
transitive dependencies here, and a check inside the image confirmed `got` is **not
traced** into `node_modules` — externalising it would have turned a bundled module into a
runtime `require` that resolves to nothing, trading zero measured gain for a possible
`MODULE_NOT_FOUND` on a code path the benchmarks never hit.
### Where the remaining headroom is
Nothing further is available from caches, Redis, or process trimming. The two real levers
left are both large:
1. **Ship less server JavaScript.** ~29% of the heap is retained bundle source. Every
package moved out of the boot graph pays twice — smaller chunks and less retained
source. This is the same lever that produced the −37% idle result.
2. **Stop the App Router re-rendering every second** (see above). That governs the
browser side, which is where the 650 MB peak lives.
## Measured but not pursued
| lever | measured cost | why not pursued |
| --- | --- | --- |
| in-image Redis (F12) | **8.1 MiB RSS** (6.3 PSS) | The catalog estimated 10–40 MB; the real figure is 8. Removing it means replacing the pub/sub bus for single-instance — real risk for ~5% of the footprint. |
+9
View File
@@ -73,6 +73,15 @@ const nextConfig: NextConfig = {
"linkedom",
// Only reachable through /api/mcp/[transport].
"@modelcontextprotocol/sdk",
// Bundling inlines a copy per chunk: a server heap snapshot found this package's
// command table in 10 chunks and resident 6 times over (2.1 MiB of duplication).
// Safe to externalise because ioredis is already external and owns this as a
// direct dependency, so it is always traced alongside it. Bundle hygiene — it did
// not move measured memory (idle 89.7 -> 89.4 MiB, inside run-to-run noise).
// got and @octokit/request-error duplicate similarly but are only transitive
// deps here, so externalising them risks a runtime require that was never traced,
// for no measured gain.
"@ioredis/commands",
],
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
@@ -0,0 +1,65 @@
// Lists the largest strings in a .heapsnapshot with a content preview, and buckets
// them by what they look like. Aggregate "37 MiB of strings" says nothing about
// whether that is base64 images, translations, or bundled source.
import { readFileSync } from "node:fs";
const snap = JSON.parse(readFileSync(process.argv[2], "utf8"));
const meta = snap.snapshot.meta;
const nf = meta.node_fields;
const stride = nf.length;
const iType = nf.indexOf("type");
const iName = nf.indexOf("name");
const iSelf = nf.indexOf("self_size");
const nodeTypes = meta.node_types[0];
const nodes = snap.nodes;
const strings = snap.strings;
const N = snap.snapshot.node_count;
const classify = (text) => {
if (/^data:[^;]+;base64,/.test(text)) return "data-URL (base64 inline image)";
if (/^[A-Za-z0-9+/]{200,}={0,2}$/.test(text.slice(0, 400))) return "bare base64 blob";
if (/^(https?:)?\/\//.test(text)) return "URL";
if (/^\s*[[{]/.test(text) && text.length > 200) return "JSON payload";
if (/^<svg|^<\?xml/.test(text)) return "inline SVG";
if (/function |=>|require\(|import /.test(text) && text.length > 400) return "source/code";
return "other text";
};
const items = [];
let totalStringBytes = 0;
for (let i = 0; i < N; i++) {
const base = i * stride;
const type = nodeTypes[nodes[base + iType]];
if (type !== "string" && type !== "concatenated string" && type !== "sliced string") continue;
const self = nodes[base + iSelf];
totalStringBytes += self;
const text = strings[nodes[base + iName]] ?? "";
items.push({ self, text });
}
items.sort((a, b) => b.self - a.self);
const MiB = (b) => (b / 1048576).toFixed(2);
console.log(`total string bytes: ${MiB(totalStringBytes)} MiB across ${items.length.toLocaleString()} strings\n`);
console.log("=== 25 largest strings ===");
console.log("KiB".padStart(9), " kind".padEnd(34), "preview");
for (const item of items.slice(0, 25)) {
const kind = classify(item.text);
const preview = item.text.replace(/\s+/g, " ").slice(0, 80);
console.log((item.self / 1024).toFixed(1).padStart(9), ` ${kind.padEnd(33)}`, preview);
}
const buckets = new Map();
for (const item of items) {
const kind = classify(item.text);
const acc = buckets.get(kind) ?? { bytes: 0, n: 0 };
acc.bytes += item.self;
acc.n++;
buckets.set(kind, acc);
}
console.log("\n=== string bytes by kind ===");
console.log("MiB".padStart(9), "count".padStart(10), " kind");
for (const [kind, v] of [...buckets.entries()].sort((a, b) => b[1].bytes - a[1].bytes)) {
console.log(MiB(v.bytes).padStart(9), String(v.n).padStart(10), ` ${kind}`);
}