diff --git a/PHASE2-FINDINGS.md b/PHASE2-FINDINGS.md index 0551a8486..402643afb 100644 --- a/PHASE2-FINDINGS.md +++ b/PHASE2-FINDINGS.md @@ -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. | diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index ba65beefe..ab735d7d6 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -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"], diff --git a/scripts/benchmarks/analyze-heap-strings.mjs b/scripts/benchmarks/analyze-heap-strings.mjs new file mode 100644 index 000000000..89040cf59 --- /dev/null +++ b/scripts/benchmarks/analyze-heap-strings.mjs @@ -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 (/^|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}`); +}