Node.js Memory Leak aufgespuert: Wie Claude Code bei der Analyse half
Ein Node.js-22-API-Dienst fraess so viel Speicher, dass Kubernetes den Container etwa alle 30 Stunden per OOM-Kill beendete. Mit Unterstuetzung von Claude Code wurde das Leck identifiziert, der Erfolg kam laut Bericht vor allem durch die verbindliche Arbeit mit Heap-Snapshots.

Einer unserer Node.js 22 API-Dienste fraß langsam Speicher, bis Kubernetes ihn etwa alle 30 Stunden per OOM-Kill beendete. Ich habe mich mit Claude Code (CLI, Build von Oktober 2026) zusammengesetzt, um das Problem aufzuspüren. Der Erfolg kam nicht daher, dass die KI die Antwort wusste, sondern daher, dass ich sie zwang, auf Basis von Heap-Snapshots statt auf Grundlage von Bauchgefühlen zu arbeiten. Zwei Leaks, ein Nachmittag und fünf Lehren, die ich an Ihrer Stelle übernehmen würde.
Der Dienst war auf die bestmögliche Weise unspektakulär: eine Express-artige HTTP-API, ein Postgres-Pool, ein Redis-Client und ein paar hundert Anfragen pro Sekunde zur Spitzenzeit. Er war ein Jahr lang stabil.
Dann begann der Speicherverbrauchsgraph wie eine Treppe auszusehen:
Wochenlang bemerkte es niemand, weil die Neustarts sich selbst heilten. Dann traf ein Neustart mitten in einen Batch-Import, und wir verloren ein paar hundert laufende Anfragen. Plötzlich war es relevant.
Mein erster Impuls war, den Speichergraphen in Claude Code einzufügen und zu fragen, warum hier etwas leckt. Genau das habe ich getan. Die Antwort war eine überzeugende Liste mit sieben plausiblen Ursachen: Closures, globale Arrays, nicht geschlossene DB-Verbindungen, Timer. Alles nachvollziehbar, nichts davon in unserem Code verankert. In diesem Moment habe ich meinen Ansatz geändert.
Statt nach Ursachen zu fragen, bat ich Claude Code, mir bei der Instrumentierung des Dienstes zu helfen. Die Regel, die ich ihm gab, war explizit:
Schlage keine Lösung vor, bevor wir zwei Heap-Snapshots haben, die zeigen, welche Objekttypen gewachsen sind.
Zuerst ein winziger Memory-Logger, damit wir sehen konnten, ob das Wachstum im V8-Heap oder außerhalb stattfand (native Buffers und so weiter):
// memory-probe.js — loaded only when MEMORY_PROBE=1 const INTERVAL_MS = 60 _000; setInterval (() => { const m = process. memoryUsage (); console. log ( JSON. stringify ({ probe: " memory ", rssMb: Math. round ( m. rss / 1 e6 ), heapUsedMb: Math. round ( m. heapUsed / 1 e6 ), externalMb: Math. round ( m. external / 1 e6 ), arrayBuffersMb: Math. round ( m. arrayBuffers / 1 e6 ), })); }, INTERVAL_MS ). unref (); Nach ein paar Stunden in der Staging-Umgebung war die Antwort klar: heapUsed folgte RSS nahezu eins zu eins. Die Buffers blieben flach. Es handelte sich also um einen klassischen Leak von JS-Objekten, was die gute Sorte ist, denn Heap-Snapshots können ihn finden.
Node hat dafür ein eingebautes Flag, und Claude Code schlug es vor, bevor ich mich daran erinnerte, dass es existiert:
node --heapsnapshot-signal = SIGUSR2 server.js Dann in Staging:
# snapshot 1, right after warm-up kill -USR2 <pid> #...wait ~3 hours under replayed traffic... # snapshot 2 kill -USR2 <pid> Um realistischen Traffic zu erhalten, ließ ich Claude Code ein kleines Replay-Skript schreiben, das eine Stichprobe anonymisierter Access-Logs nahm und sie in etwa im Produktionsprofil auf die Staging-Umgebung feuerte. Das erwies sich als der Schlüssel, denn synthetische Last auf einem einzelnen Endpoint löste den Leak nie aus.
Heap-Snapshots sind große JSON-Dateien (unsere waren jeweils etwa 250 MB), also ist das Einfügen in einen Chat ein No-Go. Stattdessen lud ich beide in Chrome DevTools, verwendete die Comparison-Ansicht, sortierte nach # Delta und exportierte die obersten Zeilen als reinen Text. Genau diese Zusammenfassung reichte ich an Claude Code weiter:
Constructor # New # Deleted # Delta Size Delta (string) 912,340 301,220 +611,120 +41.2 MB Object 455,002 120,889 +334,113 +28.7 MB Map 18,421 210 +18,211 +6.9 MB (closure) 37,550 1,104 +36,446 +3.1 MB TLSSocket 0 0 0 0 Jetzt wurde das Gespräch nützlich. Ich fragte: Angesichts dieses Deltas, liste die Stellen in unserer Codebasis auf, die langlebige Maps und langlebige Closures erzeugen. Sage mir bei jeder Stelle, ob Einträge jemals gelöscht werden.
Es kam mit einer Tabelle von elf Kandidaten zurück. Zwei stechen heraus.
Jemand (gut, das war ich, vor acht Monaten) hatte einen In-Memory-Cache für eine mandantenspezifische Preisabfrage hinzugefügt:
// before const priceCache = new Map (); export async function getPrice ( tenantId, sku, currency ) { const key = ` ${ tenantId }: ${ sku }: ${ currency } `; if ( priceCache. has ( key )) return priceCache. get ( key ); const price = await loadPriceFromDb ( tenantId, sku, currency ); priceCache. set ( key, price ); return price; } Bei einer Handvoll Mandanten war das in Ordnung. Nachdem ein großer Kunde mit rund 200.000 SKUs und vier Währungen an Bord kam, explodierte der Key-Raum. Es wurde nie etwas verdrängt. Das erklärte das Wachstum bei (string) und Object.
Die Lösung war unspektakulär, so wie Lösungen sein sollten:
// after import { LRUCache } from " lru-cache "; const priceCache = new LRUCache ({ max: 50 _000, // hard ceiling on entries ttl: 10 * 60 * 1000, // prices can change; 10 minutes is plenty }); Leak Nummer 2: Ein pro Anfrage hinzugefügter Event-Listener Das Wachstum bei (closure) und Map deutete in eine andere Richtung. Claude Code markierte diese Middleware, die einen gemeinsamen Emitter abonnierte, um den Abbruch von Anfragen zu unterstützen:
// before app. use (( req, res, next ) => { const onShutdown = () => req. socket. destroy (); lifecycle. on ( " shutdown ", onShutdown ); next (); }); Jede Anfrage fügte einem prozessweiten Emitter einen Listener hinzu und entfernte ihn nie. Jede Closure hielt eine Referenz auf req und hielt damit den gesamten Objektgraphen der Anfrage am Leben. Wir hatten sogar schon MaxListenersExceededWarning in den Logs bekommen. Vor Monaten hatte das jemand behoben, indem er lifecycle.setMaxListeners(0) aufrief. Autsch.
// after app. use (( req, res, next ) => { const onShutdown = () => req. socket. destroy (); lifecycle. on ( " shutdown ", onShutdown ); res. on ( " close ", () => lifecycle. off ( " shutdown ", onShutdown )); next (); }); Außerdem ließ ich Claude Code den Aufruf von setMaxListeners(0) entfernen und einen Test hinzufügen, der behauptet, dass die Listener-Anzahl nach 1.000 Anfragen zum Ausgangswert zurückkehrt:
test ( " shutdown listeners do not accumulate ", async () => { const before = lifecycle. listenerCount ( " shutdown " ); await Promise. all ( Array. from ({ length: 1000 }, () => request ( app ). get ( " /health " )) ); expect ( lifecycle. listenerCount ( " shutdown " )). toBe ( before ); }); Schritt 4: Beweisen statt annehmen Hier ist grob der Ablauf, den ich am Ende verwendet habe:
flowchart LR A[Memory graph looks wrong] --> B[Probe: heap vs external?] B --> C[Two heap snapshots under replayed traffic] C --> D[Comparison view: top deltas] D --> E[Agent maps deltas to code] E --> F[Fix + regression test] F --> G[Re-run replay 6h, compare again] Nach dem Deployen beider Fixes in der Staging-Umgebung und sechs Stunden repliziertem Traffic stabilisierte sich heapUsed bei rund 210 MB und blieb dort. In der Produktionsumgebung laufen die Pods inzwischen seit über zwei Wochen ohne Neustart.
Gesamtzeit: etwa 3,5 Stunden konzentrierter Arbeit, wovon der Großteil aus dem Warten auf Snapshots bestand.
Als ich nach Ursachen fragte, bekam ich eine generische Checkliste. Als ich den Agenten bat, konkrete Belege auf konkreten Code abzubilden, bekam ich zwei echte Bugs. KI-Coding-Agenten sind hervorragend im Suchen und Korrelieren über eine Codebasis hinweg, aber mittelmaß im Erraten von Laufzeitverhalten, das sie nicht beobachten können. Füttert sie mit Beobachtungen.
Ein 250-MB-Heap-Snapshot ist in einem Kontextfenster nutzlos. Eine Delta-Tabelle mit zehn Zeilen ist Gold wert. Die Kunst besteht nicht darin, dem Agenten alles zu geben, sondern die Belege auf den Teil zu verdichten, der zwischen Hypothesen unterscheidet. Das gilt genauso für Logs, Traces und Profile.
Mein Laptop-Benchmark hämmerte auf einen einzelnen Endpoint und zeigte nichts. Der Leak zeigte sich nur mit der realen Mischung aus Mandanten und SKUs. Den Agenten ein Log-Replay-Skript schreiben zu lassen war die mit Abstand wirkungsvollste Sache, die er an diesem Tag getan hat.
MaxListenersExceededWarning hat uns buchstäblich monatelang über Leak Nummer 2 informiert. Wenn Sie jemals setMaxListeners(0) geschrieben haben, schauen Sie nach, warum. Ich lasse Claude Code inzwischen im Rahmen jeder Performance-Untersuchung nach unterdrückten Warnungen greppen.
Der Listener-Count-Test läuft in 200 Millisekunden und hätte Leak Nummer 2 am Tag seiner Einführung gefunden. Für den Cache haben wir eine Assertion auf eine begrenzte Größe ergänzt. Es ist verlockend aufzuhören, wenn der Graph flach wird. Tun Sie es nicht, denn der Graph wird in sechs Monaten das Problem von jemand anderem sein.
Memory-Leaks wirken wie schwarze Magie, bis man sie als Datenproblem behandelt. Claude Code wusste nicht magisch, wo der Leak war, aber sobald ich ihm Heap-Deltas statt Bauchgefühlen gab, fand es beide Übeltäter schneller, als ich es allein geschafft hätte.
Wenn Sie eine Leak-Kriegsgeschichte parat haben (oder einen besseren Heap-Diff-Workflow), hinterlassen Sie sie in den Kommentaren 👇 Ich lese jeden.
Folgen Sie mir hier auf Dev.to für weitere Build-in-Public-Beiträge zum Arbeiten mit KI-Coding-Agenten in echten Produktions-Codebases. Als Nächstes geht es um das Soak-Test-Setup. Und falls Sie Claude Code zum Debuggen noch nicht ausprobiert haben, beginnen Sie mit einem evidence-first Prompt bei Ihrem nächsten seltsamen Bug. 🚀
Expand DEV ANTIBOT DEV ANTIBOT DEV ANTIBOT Follow Verify account - https://clck.su/gfpWR Joined Sep 28, 2026 • Oct 9 Dropdown menu Copy link Hide You need to verify your account. Link is in the profile.
Like comment: 1 like Like Comment button Reply Some comments may only be visible to logged-in visitors. Sign in to view all comments.
Automatisch übersetzt. Das Original auf Englisch ist unten aufklappbar.
Original (Englisch) anzeigen
One of our Node.js 22 API services was slowly eating memory until Kubernetes OOM-killed it roughly every 30 hours. I paired with Claude Code (CLI, October 2026 build) to hunt it down. The win didn't come from the AI "knowing" the answer — it came from forcing it to work from heap snapshots instead of hunches. Two leaks, one afternoon, and five lessons I'd steal if I were you.
The service was boring in the best way: an Express-style HTTP API, a Postgres pool, a Redis client, and a few hundred requests per second at peak. It had been stable for a year.
Then the memory graph started looking like a staircase:
Nobody noticed for weeks because the restarts were "self-healing." Then a restart landed during a batch import and we dropped a few hundred in-flight requests. Suddenly it mattered.
My first instinct was to paste the memory graph into Claude Code and ask "why is this leaking?" I did exactly that. It gave me a confident list of seven plausible causes — closures, global arrays, unclosed DB connections, timers — all reasonable, none grounded in our code. That was the moment I changed approach.
Instead of asking for causes, I asked Claude Code to help me instrument the service. The rule I gave it was explicit:
Don't propose a fix until we have two heap snapshots that show which object types grew.
First, a tiny memory logger so we could see whether the growth was in the V8 heap or outside it (native buffers, etc.):
// memory-probe.js — loaded only when MEMORY_PROBE=1 const INTERVAL_MS = 60 _000; setInterval (() => { const m = process. memoryUsage (); console. log ( JSON. stringify ({ probe: " memory ", rssMb: Math. round ( m. rss / 1 e6 ), heapUsedMb: Math. round ( m. heapUsed / 1 e6 ), externalMb: Math. round ( m. external / 1 e6 ), arrayBuffersMb: Math. round ( m. arrayBuffers / 1 e6 ), })); }, INTERVAL_MS ). unref (); Enter fullscreen mode Exit fullscreen mode After a few hours in staging, the answer was clear: heapUsed tracked RSS almost one-to-one. Buffers were flat. So this was a plain old JS object leak, which is the good kind — heap snapshots can find it.
Node has a built-in flag for this, and Claude Code suggested it before I remembered it existed:
node --heapsnapshot-signal = SIGUSR2 server.js Enter fullscreen mode Exit fullscreen mode Then in staging:
# snapshot 1, right after warm-up kill -USR2 <pid> #...wait ~3 hours under replayed traffic... # snapshot 2 kill -USR2 <pid> Enter fullscreen mode Exit fullscreen mode To get realistic traffic, I had Claude Code write a small replay script that took a sample of sanitized access logs and fired them at staging at roughly production shape. This turned out to be the key, because synthetic load on one endpoint never triggered the leak.
Heap snapshots are large JSON files (ours were ~250 MB each), so pasting them into a chat is a non-starter. Instead I loaded both into Chrome DevTools, used the Comparison view, sorted by # Delta, and exported the top rows as plain text. That summary was what I handed to Claude Code:
Constructor # New # Deleted # Delta Size Delta (string) 912,340 301,220 +611,120 +41.2 MB Object 455,002 120,889 +334,113 +28.7 MB Map 18,421 210 +18,211 +6.9 MB (closure) 37,550 1,104 +36,446 +3.1 MB TLSSocket 0 0 0 0 Enter fullscreen mode Exit fullscreen mode Now the conversation got useful. I asked it: "Given this delta, list the places in our codebase that create long-lived Maps and long-lived closures. For each, tell me whether entries are ever deleted."
It came back with a table of 11 candidates. Two stood out.
Someone (fine, it was me, eight months ago) had added an in-memory cache for a per-tenant pricing lookup:
// before const priceCache = new Map (); export async function getPrice ( tenantId, sku, currency ) { const key = ` ${ tenantId }: ${ sku }: ${ currency } `; if ( priceCache. has ( key )) return priceCache. get ( key ); const price = await loadPriceFromDb ( tenantId, sku, currency ); priceCache. set ( key, price ); return price; } Enter fullscreen mode Exit fullscreen mode With a handful of tenants this was fine. After a large customer onboarded with ~200,000 SKUs and four currencies, the key space exploded. Nothing was ever evicted. That explained the (string) and Object growth.
The fix was boring, which is how fixes should be:
// after import { LRUCache } from " lru-cache "; const priceCache = new LRUCache ({ max: 50 _000, // hard ceiling on entries ttl: 10 * 60 * 1000, // prices can change; 10 minutes is plenty }); Enter fullscreen mode Exit fullscreen mode Leak #2: An event listener added per request The (closure) and Map growth pointed somewhere else. Claude Code flagged this middleware, which subscribed to a shared emitter to support request cancellation:
// before app. use (( req, res, next ) => { const onShutdown = () => req. socket. destroy (); lifecycle. on ( " shutdown ", onShutdown ); next (); }); Enter fullscreen mode Exit fullscreen mode Every request added a listener to a process-wide emitter and never removed it. Each closure held a reference to req, which kept the entire request object graph alive. We had even been getting MaxListenersExceededWarning in the logs — someone had "fixed" that months ago by calling lifecycle.setMaxListeners(0). Ouch.
// after app. use (( req, res, next ) => { const onShutdown = () => req. socket. destroy (); lifecycle. on ( " shutdown ", onShutdown ); res. on ( " close ", () => lifecycle. off ( " shutdown ", onShutdown )); next (); }); Enter fullscreen mode Exit fullscreen mode I also had Claude Code remove the setMaxListeners(0) call and add a test that asserts the listener count returns to baseline after 1,000 requests:
test ( " shutdown listeners do not accumulate ", async () => { const before = lifecycle. listenerCount ( " shutdown " ); await Promise. all ( Array. from ({ length: 1000 }, () => request ( app ). get ( " /health " )) ); expect ( lifecycle. listenerCount ( " shutdown " )). toBe ( before ); }); Enter fullscreen mode Exit fullscreen mode Step 4: Prove it, don't assume it Here's the flow I ended up using, roughly:
flowchart LR A[Memory graph looks wrong] --> B[Probe: heap vs external?] B --> C[Two heap snapshots under replayed traffic] C --> D[Comparison view: top deltas] D --> E[Agent maps deltas to code] E --> F[Fix + regression test] F --> G[Re-run replay 6h, compare again] After deploying both fixes to staging and replaying traffic for six hours, heapUsed plateaued at ~210 MB and stayed there. In production, pods have now run for over two weeks without a restart.
Total time: about 3.5 hours of focused work, most of which was waiting for snapshots.
When I asked for causes, I got a generic checklist. When I asked the agent to map specific evidence to specific code, I got two real bugs. AI coding agents are great at search and correlation across a codebase; they're mediocre at guessing runtime behavior they can't observe. Feed them observations.
A 250 MB heap snapshot is useless in a context window. A 10-line delta table is gold. The skill isn't "give the agent everything" — it's compress the evidence to the part that discriminates between hypotheses. This applies to logs, traces, and profiles too.
My laptop benchmark hammered one endpoint and showed nothing. The leak only appeared with the real mix of tenants and SKUs. Having the agent write a log-replay script was the single highest-leverage thing it did all day.
MaxListenersExceededWarning was literally telling us about leak #2 for months. If you've ever written setMaxListeners(0), go look at why. I now ask Claude Code to grep for suppressed warnings as part of any performance investigation.
The listener-count test takes 200 ms to run and would have caught leak #2 the day it was introduced. For the cache, we added a bounded-size assertion. It's tempting to stop when the graph goes flat. Don't — the graph will be someone else's problem in six months.
Memory leaks feel like black magic until you treat them as a data problem. Claude Code didn't magically know where the leak was — but once I gave it heap deltas instead of vibes, it found both culprits faster than I would have alone.
If you've got a leak war story (or a better heap-diff workflow), drop it in the comments 👇 I read every one.
Follow me here on Dev.to for more build-in-public posts on working with AI coding agents in real production codebases — next up is the soak-test setup. And if you haven't tried Claude Code for debugging yet, start with an evidence-first prompt on your next weird bug. 🚀
Expand DEV ANTIBOT DEV ANTIBOT DEV ANTIBOT Follow Verify account - https://clck.su/gfpWR Joined Sep 28, 2026 • Oct 9 Dropdown menu Copy link Hide You need to verify your account. Link is in the profile.
Like comment: 1 like Like Comment button Reply Some comments may only be visible to logged-in visitors. Sign in to view all comments.