●MEMORY — Claude Code closes a cluster of long-session memory leaks, including MCP stdio stderr piling up to 64 MB per server and LSP documents staying open indefinitely●TABLES — Very large markdown tables no longer stall rendering; tables over 200 rows now show the first 200 with a "… N more rows" notice●SPEED — Sessions carrying many deny/ask permission rules no longer lose seconds every turn: rule matchers are compiled once and cached●TOOLS — print/SDK sessions with many MCP tools get up to 7x faster tool rounds thanks to cached tool-pool assembly●ARTIFACTS — Claude Code Artifacts turn session work into live, shareable web pages that update in place — useful for PR walkthroughs and dashboards●DEADLINE — Opus 4.7 fast mode is removed on July 24; speed: "fast" will error, so move to Opus 4.8 fast mode before then●MEMORY — Claude Code closes a cluster of long-session memory leaks, including MCP stdio stderr piling up to 64 MB per server and LSP documents staying open indefinitely●TABLES — Very large markdown tables no longer stall rendering; tables over 200 rows now show the first 200 with a "… N more rows" notice●SPEED — Sessions carrying many deny/ask permission rules no longer lose seconds every turn: rule matchers are compiled once and cached●TOOLS — print/SDK sessions with many MCP tools get up to 7x faster tool rounds thanks to cached tool-pool assembly●ARTIFACTS — Claude Code Artifacts turn session work into live, shareable web pages that update in place — useful for PR walkthroughs and dashboards●DEADLINE — Opus 4.7 fast mode is removed on July 24; speed: "fast" will error, so move to Opus 4.8 fast mode before then
Your Overnight Session Wakes Up at 3GB — Four Places Memory Piles Up, and How to Tell Them Apart
The Claude Code process I left running overnight had grown to 3.4GB of resident memory by morning. Here are the four accumulation sources closed in 2.1.209, how to separate what's left in your own setup by sampling RSS slope, and a watchdog pattern that folds a session before it hurts.
I still remember the number I saw when I ran ps first thing that morning. The Claude Code process I had left running overnight was sitting at 3.4GB of resident memory. When I started it the night before, it had been in the 400MB range. The work itself had finished, and the output was fine. But the last stretch of turns had been visibly sluggish — each response took noticeably longer to come back.
For a while I filed this under "well, it ran a long time." When you run scheduled work as an indie developer, nobody is watching the night shift. You check the results in the morning and accept the slowness as the price of leaving something unattended.
Then I read through the memory-related fixes in 2.1.209 and 2.1.208. Four of them, and reading closely, that slowness wasn't one vague thing called "running long." It was four separate accumulations happening in four separate places for four separate reasons. And some of them, I had invited in myself.
It Wasn't Piling Up in One Place
The four fixes are different in kind. If you read them as a single "memory leaks fixed" line, you'll misjudge which one matters in your environment.
Source
What was happening
After the fix
Setups it hits
MCP stdio server stderr
Up to 64MB of output retained per server
Retention capped
Several homegrown MCP servers resident, logging to stderr
LSP documents
Opened documents never released
LRU capped at 50
Sweeping a large repo, opening many files in sequence
Async hook output
Output lingered after backgrounding
Released on backgrounding
Hooks in place, long work pushed to the background
Tool results (headless / SDK)
Large results accumulated without bound
Accumulation capped
claude -p or SDK runs where tools return long output
My setup matched three of the four. Two homegrown MCP servers resident, both streaming progress to stderr. Nightly work running through claude -p unattended. Hooks in the mix. The one that didn't apply was LSP, only because I keep the model scoped to a small slice of a single repo.
This is where the fork is. Take that one-line "memory leaks fixed" and split it into four against your own configuration. Then you have an expectation for how much should improve after updating — and when the measurement disagrees with the expectation, you know the remainder lives in your own design.
Check Your Version Before You Measure
Confirm what you're running before sampling anything. A baseline taken on a build without the fixes throws off every comparison that follows.
claude --version
The decision is simple.
Your version
What to do
Before 2.1.208
Sample one night before updating. The delta is exactly what the four fixes bought you
2.1.208 / 2.1.209 or later
Sample as is. If it still climbs, the remainder is your configuration
That one pre-update night is worth more than it looks. It turns "feels lighter after updating" into "growth went from 620 MB/h to 95 MB/h." The first is useless the next time things get heavy. The second is a baseline.
✦
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
✦Separate the four accumulation sources — MCP stdio stderr, LSP documents, async hook output, and tool results — so you can judge which ones your own setup actually invites
✦Take home a 50-line sampler that records RSS across the whole process tree plus an analyzer that reports growth in MB/h, so you diagnose with numbers instead of impressions
✦Apply a watchdog-plus-checkpoint pattern that folds an oversized session and resumes it, keeping unattended runs healthy through the night
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.
Resident set size is what you want. And the part that matters: don't watch only the Claude Code process. MCP servers live as separate child processes. The 64MB of stderr was piling up on the child side — watch only the parent and you miss the biggest contributor.
Here's a sampler that writes RSS for the whole process tree to CSV every minute. It runs on both macOS and Linux.
#!/usr/bin/env bash# rss-sampler.sh — record RSS of Claude Code and its children every minute# usage: ./rss-sampler.sh out.csv 60set -euo pipefailOUT="${1:-rss.csv}"INTERVAL="${2:-60}"# collect descendant PIDs recursivelydescendants() { local pid=$1 local children children=$(pgrep -P "$pid" 2>/dev/null || true) for c in $children; do echo "$c" descendants "$c" done}echo "ts,pid,rss_kb,command" > "$OUT"while true; do TS=$(date +%Y-%m-%dT%H:%M:%S) # find claude processes (pick up every session if several are running) ROOTS=$(pgrep -f '(^|/)claude( |$)' 2>/dev/null || true) if [ -z "$ROOTS" ]; then echo "$TS,,,no-process" >> "$OUT" sleep "$INTERVAL" continue fi for root in $ROOTS; do ALL="$root $(descendants "$root")" for pid in $ALL; do # ps -o rss=,comm= behaves the same on macOS and Linux LINE=$(ps -o rss=,comm= -p "$pid" 2>/dev/null || true) [ -z "$LINE" ] && continue RSS=$(echo "$LINE" | awk '{print $1}') CMD=$(echo "$LINE" | awk '{$1=""; sub(/^ /,""); print}') echo "$TS,$pid,$RSS,\"$CMD\"" >> "$OUT" done done sleep "$INTERVAL"done
One gotcha caught me here. A loose pgrep -f claude pattern picks up the sampler itself and unrelated processes, inflating the numbers. Anchoring on a path separator and a word boundary works around it. Before you doubt the measurement, doubt what you're counting.
pgrep -P walks the children, and the recursion reaches grandchildren. If your MCP server runs under node, that node process shows up here. Because ps -o rss=,comm= takes the same form on macOS and Linux, the same script runs on your laptop and on a box.
Three steps, no more:
Launch the sampler right before the nightly job, writing to a date-stamped CSV
Run the task itself under claude -p and wait for it (the sampler keeps ticking behind it)
Stop the sampler when the task ends, then feed the CSV to the analyzer below
Once the CSV fills up, look at the slope rather than the peak. A peak alone is always explainable as "it was a heavy task." Growth per hour separates real accumulation from a job that simply handled a lot of data.
#!/usr/bin/env python3"""rss-slope.py — report growth (MB/h) from RSS samples.usage: python3 rss-slope.py rss.csv"""import csvimport sysfrom collections import defaultdictfrom datetime import datetimedef load(path): """sum RSS across all processes per timestamp""" totals = defaultdict(int) per_cmd = defaultdict(lambda: defaultdict(int)) with open(path, newline="", encoding="utf-8") as f: for row in csv.DictReader(f): if not row["rss_kb"]: continue ts = datetime.fromisoformat(row["ts"]) kb = int(row["rss_kb"]) totals[ts] += kb per_cmd[row["command"].strip('"')][ts] += kb return totals, per_cmddef slope_mb_per_hour(series): """least squares slope (x = elapsed hours, y = RSS in MB)""" items = sorted(series.items()) if len(items) < 3: return None, None, None t0 = items[0][0] xs = [(t - t0).total_seconds() / 3600 for t, _ in items] ys = [kb / 1024 for _, kb in items] n = len(xs) mx = sum(xs) / n my = sum(ys) / n denom = sum((x - mx) ** 2 for x in xs) if denom == 0: return None, None, None slope = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / denom return slope, ys[0], max(ys)def main(): totals, per_cmd = load(sys.argv[1]) slope, start, peak = slope_mb_per_hour(totals) if slope is None: print("not enough samples") return print(f"TOTAL start {start:7.0f} MB / peak {peak:7.0f} MB / slope {slope:+7.1f} MB/h") print("-" * 62) rows = [] for cmd, series in per_cmd.items(): s, st, pk = slope_mb_per_hour(series) if s is None: continue rows.append((s, cmd, st, pk)) for s, cmd, st, pk in sorted(rows, reverse=True): print(f"{cmd[:28]:28s} start {st:6.0f} / peak {pk:6.0f} / slope {s:+7.1f} MB/h")if __name__ == "__main__": main()
Breaking the slope out per process name is the load-bearing part. The total only tells you "it grows." The breakdown tells you whether the parent or one of your own MCP servers is doing the growing.
On my setup — a Mac mini M2, unattended sessions of roughly three hours, two homegrown MCP servers — the before and after looked like this.
Process
Slope before
Slope after
Total
+620 MB/h
+95 MB/h
claude (parent)
+310 MB/h
+70 MB/h
node (my MCP, progress logs to stderr)
+280 MB/h
+15 MB/h
node (my MCP, quiet logging)
+30 MB/h
+10 MB/h
Across the total, +620 MB/h became +95 MB/h — roughly an 85% reduction. But carrying only that line away leads you astray. In the breakdown, most of the drop belongs to my own MCP server, whose slope shifted by about 19x.
Two things stood out once the numbers were side by side.
First, the biggest contributor wasn't the parent at all. It was my own MCP server, the one dumping progress into stderr. A 64MB ceiling fills up comfortably over a single night — and I had it printing one line per file processed. Debug output I wrote months ago, carried straight into unattended operation.
Second, updating doesn't take it to zero. The +95 MB/h stays. Over three hours that's 285MB, which hurts nobody. Over eight hours it's a different conversation. That remainder isn't a missed fix. It's what my configuration brings in.
What's Left Belongs to Your Design
Capping accumulation and preventing accumulation aren't the same thing. A cap stops unbounded growth; it doesn't make filling the cap a good idea. A setup that pins itself to the ceiling pays cap × server-count as a fixed resident cost, and the longer the session, the more that cost defines the run.
The first thing I changed was how tool results come back. One of my MCP tools was returning several hundred lines of file listing verbatim. Claude Code now renders very large tables as the first 200 rows plus "… N more rows" — but that's the rendering layer. The data handed to the model doesn't get thinner. Pass long output and it stays in the session.
Fold it on the way out instead.
// mcp-server/src/truncate.tsconst MAX_ROWS = 60;const MAX_CHARS = 8_000;type ToolResult = { content: Array<{ type: "text"; text: string }> };/** * Cap a tool result on both rows and characters. * Always keep the fact of omission and the total count — the number * alone is enough for the model to decide it needs to narrow the query. */export function capResult(rows: string[], label: string): ToolResult { const total = rows.length; let shown = rows.slice(0, MAX_ROWS); let text = shown.join("\n"); if (text.length > MAX_CHARS) { // over the char budget: trim rows further (insurance against very long lines) while (shown.length > 1 && text.length > MAX_CHARS) { shown = shown.slice(0, Math.floor(shown.length * 0.8)); text = shown.join("\n"); } } const omitted = total - shown.length; const footer = omitted > 0 ? `\n\n[${label}: showing ${shown.length} of ${total} / ${omitted} omitted]` : `\n\n[${label}: ${total} total]`; return { content: [{ type: "text", text: text + footer }] };}
Attaching the omitted count is the whole point. Truncate silently and the model treats what it sees as the complete set and moves on. Leave the count and the reasoning lands on the other side: 60 of 480 visible, so narrow the filter. I truncated silently for a while, and the result was a morning where only the files in the back half of a listing had gone untouched.
The stderr side is plainer.
// progress logs off by default; open them with an env var when debuggingconst VERBOSE = process.env.MCP_VERBOSE === "1";export function trace(msg: string) { if (!VERBOSE) return; process.stderr.write(`${new Date().toISOString()} ${msg}\n`);}
Open it with MCP_VERBOSE=1 while debugging, keep it shut for unattended runs. That alone dropped my MCP server's slope from +280 MB/h to +15 MB/h. I could have written this line long before any fix shipped.
Pick a Threshold and Fold the Session
Thinning the design doesn't get you to zero growth. For long nights, assume it keeps climbing and build the fold in.
This watchdog ends a session gracefully once RSS crosses a threshold and starts the next one. SIGTERM first, with a grace period, rather than a hard kill.
#!/usr/bin/env bash# rss-watchdog.sh — fold and resume a session when RSS crosses a threshold# usage: ./rss-watchdog.sh 2048 task.md checkpoint.jsonset -euo pipefailTHRESHOLD_MB="${1:-2048}"TASK_FILE="${2:?task file required}"CHECKPOINT="${3:-checkpoint.json}"MAX_ROUNDS=6tree_rss_mb() { local root=$1 total=0 local pids pids="$root $(pgrep -P "$root" 2>/dev/null || true)" for p in $pids; do local kb kb=$(ps -o rss= -p "$p" 2>/dev/null | tr -d ' ' || true) [ -n "${kb:-}" ] && total=$((total + kb)) done echo $((total / 1024))}for round in $(seq 1 "$MAX_ROUNDS"); do echo "[round $round] start" # hand the checkpoint over so the run is resumable claude -p "$(cat "$TASK_FILE") Read progress from ${CHECKPOINT} and skip anything already done. Update ${CHECKPOINT} after every completed step." \ --output-format stream-json > "session-${round}.jsonl" & PID=$! while kill -0 "$PID" 2>/dev/null; do MB=$(tree_rss_mb "$PID") if [ "$MB" -gt "$THRESHOLD_MB" ]; then echo "[round $round] RSS ${MB}MB > ${THRESHOLD_MB}MB — folding" kill -TERM "$PID" 2>/dev/null || true sleep 20 kill -KILL "$PID" 2>/dev/null || true break fi sleep 30 done wait "$PID" 2>/dev/null || true # stop once the checkpoint reports completion if grep -q '"done"[[:space:]]*:[[:space:]]*true' "$CHECKPOINT" 2>/dev/null; then echo "finished on round $round" exit 0 fi echo "[round $round] resuming"doneecho "hit the round limit — inspect the checkpoint" >&2exit 1
Choosing the threshold took some thought. Set it low and sessions fragment, and every restart costs you the time to rebuild context. Set it high and the weight becomes real damage before the fold. I settled on a rule of thumb: starting RSS + expected hours × slope × 1.5. With a 400MB start, three hours, and +95 MB/h, that's 400 + 285 × 1.5 ≈ 830MB. I run with headroom on top, around 1,200MB.
If you haven't measured your slope yet, in this case I'd recommend against picking a threshold at all — run the sampler alone for one night first. A threshold without a baseline gives you either premature fragmentation or late detection, and nothing else.
The checkpoint.json isn't optional here. Folding only works if the resumed session doesn't start over. Have each step write its progress and a fold costs you exactly the one step that was in flight.
What I Actually Run, By Situation
Situation
What I run
Why
Interactive, ~30 minutes
Nothing
The slope never becomes real damage at this length. Measuring costs more than it returns
Unattended, ~3 hours overnight
Close stderr + cap tool results
Thinning the design alone kept it from reaching the threshold
8+ hours / weekend-long runs
The above + watchdog + checkpoint
With a nonzero slope, enough hours will always find the ceiling
Three or more MCP servers resident
Measure slope per server
The total won't tell you which server is responsible
The row I keep reminding myself about is the first one. Measuring can become the goal, and then you find yourself instrumenting a 30-minute task and feeling productive. Slope only earns its keep when you're leaving the machine alone for a long stretch.
Where to Start
Add the sampler to tonight's job. When the CSV has a night in it, take the slope — per process name, not just the total.
In my case, what showed up wasn't Claude Code. It was a line of debug output I wrote and forgot. Reading release notes and hoping is slower than looking at the number for what you're leaking yourself.
If you leave unattended runs going for hours at a stretch, some of this probably rings a bell. I'm still feeling my way through much of it, but the measurements have been honest with me every time — and that much, at least, has never let me down.
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.