●DIR950 — Claude's connector directory now lists more than 950 MCP servers, so discovery is solved and the differentiator has shifted to clear descriptions and fine-grained permissions●RECSKILL — Record a Skill landed on July 21: screen-record yourself doing a task while narrating, and Claude turns it into a skill it can replay (Pro, Max, and Team)●COWORK — On July 7 Cowork expanded to the web at claude.ai and to iOS and Android in beta, with remote sessions running server-side so scheduled work continues with every device closed●DEFOPUS — Opus 5 is now the default Opus model in Claude Code, so untouched configs point somewhere new — pin the model explicitly if you need costs to stay predictable●LEAKFIX — A leak that kept the full untruncated result of truncated MCP tool output in memory for the rest of the session is fixed, along with Windows auto-update failures●CTRL — Emoji shortcode autocomplete and clearer transcript write warnings arrive alongside tighter controls for subagents, budgets, and background sessions●DIR950 — Claude's connector directory now lists more than 950 MCP servers, so discovery is solved and the differentiator has shifted to clear descriptions and fine-grained permissions●RECSKILL — Record a Skill landed on July 21: screen-record yourself doing a task while narrating, and Claude turns it into a skill it can replay (Pro, Max, and Team)●COWORK — On July 7 Cowork expanded to the web at claude.ai and to iOS and Android in beta, with remote sessions running server-side so scheduled work continues with every device closed●DEFOPUS — Opus 5 is now the default Opus model in Claude Code, so untouched configs point somewhere new — pin the model explicitly if you need costs to stay predictable●LEAKFIX — A leak that kept the full untruncated result of truncated MCP tool output in memory for the rest of the session is fixed, along with Windows auto-update failures●CTRL — Emoji shortcode autocomplete and clearer transcript write warnings arrive alongside tighter controls for subagents, budgets, and background sessions
The 400-Character Preview That Was Still Holding On to a Megabyte
Truncating tool output in your own MCP server does not free the original. Node's sliced strings keep the parent alive. Here is the measured 13-character boundary, which flattening tricks actually work, and a heap-snapshot audit script that counts the retained parents for you.
I wrote a very small MCP server whose only job was to read my own build logs. One tool, one file read, one string returned. To keep the return value manageable I stored only the first 400 characters as a preview and left the full text to be re-read on demand.
As an indie developer running several sites at once, I accumulate little resident processes like this one at a steady drip.
Each was supposed to cost me a few dozen megabytes.
A few hours into running it, ps stopped me cold. The resident set of that little process had crossed 200 MB.
The history array was supposed to hold nothing but 400-character previews. Hundreds of them still add up to tens of kilobytes. The memory did not come down.
I spent a long time looking in the wrong places: unconsumed streams, Buffer handles I had forgotten to release, event listeners piling up on a long-lived emitter. None of it.
The culprit was the single line of slice I had written to do the truncating.
A 400-character preview that will not let go of its parent
Start with the smallest reproduction I could build. Create a 4 MB string twenty times, keep only the first 200 characters of each.
// exp1.mjs — node --expose-gc exp1.mjsfunction mb(n) { return (n / 1048576).toFixed(1); }const KEEP = 200;const N = 20;const SIZE = 4 * 1024 * 1024;const kept = [];global.gc();const base = process.memoryUsage().heapUsed;for (let i = 0; i < N; i++) { const full = 'x'.repeat(SIZE) + '#' + i; // stand-in for a big tool result kept.push(full.slice(0, KEEP)); // "keep only 200 characters"}global.gc();const after = process.memoryUsage().heapUsed;console.log('kept =', kept.length, '/ previewLen =', kept[0].length, '/ heapDelta =', mb(after - base), 'MB');
The explicit global.gc() calls are there so the result does not depend on when a collection happens to fire. They require the --expose-gc flag.
Every number below comes from Node.js v22.22.3 (V8 12.4.254.21) on Linux x86_64, 4 vCPU, 3.9 GB of RAM, inside a sandbox.
Twenty strings of 200 characters. Four thousand characters in total. Eighty megabytes unreclaimed — exactly 4 MB times 20.
All twenty parents are still alive.
The boundary sits at exactly 13 characters
The obvious follow-up question is how short a preview has to be before it stops pinning the parent. Same script, varying only KEEP, this time with 2 MB parents and ten iterations.
Preview length (chars)
Unreclaimed
Reading
1
2.00 MB
parent freed
5
2.00 MB
parent freed
11
2.00 MB
parent freed
12
2.00 MB
still safe
13
20.00 MB
parent pinned
14
20.00 MB
parent pinned
200
20.00 MB
parent pinned
Between 12 and 13, the number jumps by an order of magnitude.
The residual 2.00 MB is the last full still being reachable when the measurement runs. It stayed at 2.00 MB whether I looped once or forty times, so I treated it as a constant floor. Above the threshold, the growth is strictly linear.
Calls
Unreclaimed (200-char preview, 4 MB parent)
1
4.00 MB
2
8.00 MB
5
20.00 MB
10
40.00 MB
20
80.00 MB
40
160.00 MB
A perfectly straight line. The longer the process runs, the worse it gets.
That 13 lines up with the minimum length at which V8 switches to representing a substring as a reference to its parent plus an offset and a length — a sliced string. Anything shorter is simply copied on the spot, which is why the parent goes away.
Note how that inverts the usual intuition: shorter is safer, but only below a threshold too small to carry any useful information. Trimming your previews to 12 characters technically fixes the leak and destroys the feature.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦The measured 13-character threshold where a preview starts pinning the entire parent string alive (4 MB becomes 80 MB over 20 calls)
✦A side-by-side table of 5 flattening techniques that work and 7 that silently do not, including template literals, String(), and repeat(1)
✦A 200-call tool wrapper benchmark: heapUsed 203.5 MB to 4.6 MB, RSS 245.0 MB to 43.6 MB, for a total cost of 16 milliseconds
✦A 50-line audit script that walks a heap snapshot, sums the retained parents, and exits 1 when they blow a byte budget
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
So which rewrites actually detach the parent? I lined up every idea I had and measured them under identical conditions — 4 MB parents, twenty iterations, 200-character previews.
// exp2b.mjs — one technique per process, so leftovers cannot contaminate the next runconst F = { 'slice': s => s.slice(0, KEEP), 'substring': s => s.substring(0, KEEP), 'template': s => `${s.slice(0, KEEP)}`, 'String()': s => String(s.slice(0, KEEP)), 'concat-empty': s => '' + s.slice(0, KEEP), 'space-slice1': s => (' ' + s.slice(0, KEEP)).slice(1), 'split-join': s => s.slice(0, KEEP).split('').join(''), 'buffer': s => Buffer.from(s.slice(0, KEEP), 'utf8').toString('utf8'), 'json': s => JSON.parse(JSON.stringify(s.slice(0, KEEP))), 'array-join': s => Array.from(s.slice(0, KEEP)).join(''), 'repeat1': s => s.slice(0, KEEP).repeat(1), 'padEnd': s => s.slice(0, KEEP).padEnd(KEEP), 'normalize': s => s.slice(0, KEEP).normalize(),};
Technique
Unreclaimed
Parent detached?
s.slice(0, n)
80.00 MB
no
s.substring(0, n)
80.00 MB
no
`${s.slice(0, n)}`
80.00 MB
no
String(s.slice(0, n))
80.00 MB
no
'' + s.slice(0, n)
80.00 MB
no
s.slice(0, n).repeat(1)
80.00 MB
no
s.slice(0, n).padEnd(n)
80.00 MB
no
s.slice(0, n).normalize()
80.00 MB
no
(' ' + s.slice(0, n)).slice(1)
4.00 MB
yes
s.slice(0, n).split('').join('')
4.00 MB
yes
Buffer.from(...).toString('utf8')
4.01 MB
yes
JSON.parse(JSON.stringify(...))
4.00 MB
yes
Array.from(...).join('')
4.00 MB
yes
I had put my money on the template literal and on String(). Both read like "construct a fresh string." Both sail straight through.
So do '' + s, repeat(1), and padEnd called with the string's existing length. From the engine's point of view, returning the input unchanged is the correct answer whenever the output would be identical. The copy we were counting on as a side effect is precisely what the optimizer exists to remove.
What works is only the rewrites that force the internal representation to be rebuilt: prepend a character and strip it, explode into an array and rejoin, round-trip through bytes or JSON. Every one of them looks like pointless work, and every one of them is the first thing a reviewer would delete.
The fastest fix is the one that looks most wasteful
I measured the cost too: 200 characters extracted from a 256 KB source, ten thousand times, median of seven runs.
Technique
Per 10,000 calls
Per call
slice (no flattening)
0.87 ms
0.09 µs
(' ' + x).slice(1)
2.17 ms
0.22 µs
Buffer round-trip
5.27 ms
0.53 µs
JSON round-trip
5.47 ms
0.55 µs
split('').join('')
21.78 ms
2.18 µs
Array.from(...).join('')
30.73 ms
3.07 µs
The scrappiest-looking option, (' ' + x).slice(1), is the fastest of the working set — roughly 2.4 times quicker than the Buffer round-trip.
Meanwhile the two techniques that feel the most thorough, going through a character array, are more than ten times slower than that. Reasonable in hindsight, but I did not expect confidence and speed to sort in opposite directions.
The catch with (' ' + x).slice(1) is that nothing in the code explains why it exists. My compromise: Buffer round-trip wherever the cost is invisible, (' ' + x).slice(1) only on hot paths, and a comment either way.
Putting it in a tool wrapper and measuring again
Everything so far is synthetic. Here is the same question shaped like a real wrapper: a tool that returns 1 MB, called 200 times, with a 400-character preview kept in history.
// exp10.mjs — node --expose-gc exp10.mjs [naive|flatten]const FLATTEN = process.argv[2] === 'flatten';const CALLS = 200;const OUT = 1024 * 1024;const KEEP = 400;function truncatePreview(s, keep) { if (s.length <= keep) return s; const head = s.slice(0, keep); // head still references the whole parent; the round-trip cuts it loose. return FLATTEN ? Buffer.from(head, 'utf8').toString('utf8') : head;}async function callTool(i) { return 'L'.repeat(OUT) + '\n#' + i; // stand-in for a log-reading tool}const history = [];global.gc();const t0 = Date.now();for (let i = 0; i < CALLS; i++) { const out = await callTool(i); history.push({ tool: 'read_log', preview: truncatePreview(out, KEEP), fullBytes: out.length });}global.gc();const m = process.memoryUsage();console.log(FLATTEN ? 'flatten' : 'naive ', 'heapUsed =', (m.heapUsed / 1048576).toFixed(1), 'MB', '/ rss =', (m.rss / 1048576).toFixed(1), 'MB', '/ elapsed =', Date.now() - t0, 'ms');
Four runs each, with almost no variance.
Implementation
heapUsed
RSS
Elapsed
Chars in history
naive (plain slice)
203.5 MB
245.0 MB
116–124 ms
80,000
flatten (Buffer round-trip)
4.6 MB
43.6 MB
133–142 ms
80,000
Identical information retained. About 44 times less heap, about 5.6 times less RSS.
The bill is 16 milliseconds across 200 calls — 0.08 ms each. Against the latency of the tool itself, that is noise.
Keeping fullBytes separately is deliberate. Throwing away the text while remembering how large it was lets you judge later whether the truncation was reasonable, and it makes it far less likely that the model reads a preview and assumes it is the whole thing.
Turning suspicion into evidence with a heap snapshot
"Memory is growing" is not the same as "sliced strings are the cause." Being able to count the retained parents turns the fix into something you can verify before and after.
Node ships v8.writeHeapSnapshot(), and the .heapsnapshot it produces is JSON. No external tooling required.
Snapshot nodes have a type of sliced string, and an internal edge from each one points at its parent. Follow that edge, filter to parents above a size floor, and sum.
#!/usr/bin/env node// sliced_audit.mjs// Count sliced strings in a heap snapshot and the parents they keep alive.// Usage: node sliced_audit.mjs <snapshot> [min parent bytes] [budget MB]import fs from 'node:fs';const file = process.argv[2];const PARENT_MIN_BYTES = Number(process.argv[3] || 262_144); // only parents >= 256 KBconst BUDGET_MB = Number(process.argv[4] || 8);const snap = JSON.parse(fs.readFileSync(file, 'utf8'));const { node_fields: nf, node_types: nt, edge_fields: ef, edge_types: et } = snap.snapshot.meta;const NS = nf.length, ES = ef.length;const TYPE = nf.indexOf('type'), NAME = nf.indexOf('name'), SIZE = nf.indexOf('self_size'), ECOUNT = nf.indexOf('edge_count');const ETYPE = ef.indexOf('type'), ETO = ef.indexOf('to_node');const nodeTypes = nt[0], edgeTypes = et[0];// Precompute node index -> first edge index. Counting from the top each time is O(n^2).const edgeStart = new Uint32Array(snap.nodes.length / NS);for (let i = 0, o = 0, e = 0; o < snap.nodes.length; o += NS, i++) { edgeStart[i] = e; e += snap.nodes[o + ECOUNT];}// A Map keyed by parent offset prevents double counting when several slices share a parent.const parents = new Map();let sliced = 0;for (let i = 0, o = 0; o < snap.nodes.length; o += NS, i++) { if (nodeTypes[snap.nodes[o + TYPE]] !== 'sliced string') continue; sliced++; const base = edgeStart[i] * ES; for (let k = 0; k < snap.nodes[o + ECOUNT]; k++) { const eo = base + k * ES; if (edgeTypes[snap.edges[eo + ETYPE]] !== 'internal') continue; const po = snap.edges[eo + ETO]; const bytes = snap.nodes[po + SIZE]; if (bytes >= PARENT_MIN_BYTES) parents.set(po, bytes); }}let retained = 0;const samples = [];for (const [po, bytes] of parents) { retained += bytes; if (samples.length < 3) { samples.push({ parentMB: +(bytes / 1048576).toFixed(2), head: (snap.strings[snap.nodes[po + NAME]] || '').slice(0, 32), }); }}const retainedMB = +(retained / 1048576).toFixed(2);console.log(JSON.stringify({ slicedStrings: sliced, retainedParents: parents.size, retainedMB, budgetMB: BUDGET_MB, samples,}));process.exit(retainedMB > BUDGET_MB ? 1 : 0);
The edgeStart table is the part worth keeping. Snapshot edges live in one flat array for the whole heap, so finding a given node's edges means summing every preceding node's edge count. Written naively that is a nested loop, and past a few tens of thousands of nodes the audit itself starts taking tens of seconds.
Run against a process that stacked fifty 2 MB outputs:
Fifty parents, 100 MB, right there in the output. The flattened build shows one parent and 2 MB. The head field gives you the first characters of what is being retained, which is usually enough to identify the offending tool.
The audit took 0.09–0.10 seconds against a 5 MB snapshot. Cheap enough for CI.
One caveat: slicedStrings will never be zero. Libraries slice short strings constantly and that is entirely fine. The number that carries signal is the summed size of parents above your floor, not the count.
In Python the trap sits one step to the side
I ran the same shape in Python, and the answer flips. Slicing a str genuinely copies.
import tracemallocSIZE, N, KEEP = 4 * 1024 * 1024, 20, 200tracemalloc.start()base = tracemalloc.get_traced_memory()[0]kept = [('x' * SIZE + '#' + str(i))[:KEEP] for i in range(N)]cur = tracemalloc.get_traced_memory()[0]print(f"retained = {(cur - base) / 1048576:.2f} MB")
Python 3.10.12 reports retained = 4.00 MB — one parent's worth, with no accumulation.
Swap in a memoryview over bytes and the trap reappears.
kept.append(memoryview(full)[:KEEP]) # full is bytes
That one reports retained = 80.01 MB. A memoryview is a zero-copy view by definition, so this is the documented behavior rather than a surprise — but it is the same failure mode wearing different clothes.
The general lesson is that this is not a Node defect. Every language draws the line between "slicing copies" and "slicing views" somewhere, and it is worth knowing where yours draws it before you store the result.
Anthropic shipped a fix for exactly this shape of bug on their side, where the untruncated text of a truncated MCP tool result stayed in memory for the life of the session. If the official implementation could hold on to it, there is no reason to assume your own wrapper does not.
What to do before you leave a process running
Three habits are all I keep now.
Funnel every truncation through one function. Scattering the flattening across individual slice calls guarantees you will miss one. Have a single truncateForHistory() and forbid pushing strings into history anywhere else.
Wire writeHeapSnapshot() into long-lived processes. Dumping a snapshot on a signal is enough. Being able to grab the artifact the moment things look wrong is what turns diagnosis from guesswork into measurement.
Run the audit with a budget in CI. The script above exits 1 when the total crosses your ceiling. Expressing the threshold in bytes rather than in counts turned out to be the practical choice.
And no, you should not flatten every slice. If the substring is consumed and discarded immediately, flattening is pure overhead. It only earns its keep when the result goes into something long-lived — history, caches, metric labels. Going back through the code I write as an indie developer, those three covered every real case.
If you have an MCP server or an agent sitting resident right now, start by driving a few hundred calls through it under node --expose-gc and watching heapUsed. If it climbs, the truncation line is the first place I would look.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.