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:
3 files changed
+129
No files matched your search
@@ -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}`);
|
||||
}
|
||||
Reference in new issue
Block a user