Memory Leaks in Node.js debuggen
Ein Node-Prozess, dessen Speicherverbrauch über Stunden oder Tage langsam steigt und der schließlich vom Container-Orchestrator wegen OOM beendet wird, ist eine der frustrierenderen Fehlerklassen – er lässt sich lokal nie reproduzieren, die Logs kurz vor dem Absturz sehen völlig normal aus, und „den Pod neu starten" lässt das Symptom verschwinden, ohne irgendetwas zu beheben. So findet man das Leck tatsächlich, statt es nur zu umgehen.
Was ein „Leck" in einer Sprache mit Garbage Collection bedeutet
Node hat keine manuelle Speicherverwaltung, ein Leck ist also kein fehlendes free() – es ist ein Objekt, das von einer Stelle, die der Garbage Collector als Wurzel betrachtet (globaler Scope, eine aktive Closure, eine langlebige Datenstruktur), noch erreichbar ist, lange nachdem dein Code logisch damit fertig ist. Der GC kann es nicht einsammeln, weil du es seiner Kenntnis nach noch verwenden könntest. Der Bug lautet immer „ich halte noch eine Referenz darauf", nie „ich habe vergessen, es freizugeben".
Die üblichen Verdächtigen
Wachsende Collections ohne Verdrängung. Eine Map oder ein Array als Cache, das nur .set()/.push()-Aufrufe bekommt und nie aufgeräumt wird:
// leaks: every request adds an entry, nothing ever removes one
const responseCache = new Map();
app.get('/api/report/:id', async (req, res) => {
if (!responseCache.has(req.params.id)) {
responseCache.set(req.params.id, await generateReport(req.params.id));
}
res.json(responseCache.get(req.params.id));
});
Jede jemals angefragte eindeutige id bleibt für immer im Speicher. Die Lösung ist eine Begrenzung: ein LRU-Cache, der den am längsten nicht genutzten Eintrag über einer Größengrenze verdrängt, oder eine TTL, die Einträge nach fester Zeit ablaufen lässt.
Event-Listener, die das überleben, woran sie hängen. Node's EventEmitter hält eine starke Referenz auf jeden registrierten Listener. Hängt man einen Listener in einer Funktion an, die wiederholt läuft, und entfernt ihn nie, häufen sich die Listener an – und jeder schließt über alle Variablen in seinem Scope ein und hält diese ebenfalls am Leben.
// leaks: a new listener added every time this runs, old ones never removed
function watchConnection(connection) {
dbPool.on('error', (err) => {
logger.error(`Connection error: ${err.message}`, { connectionId: connection.id });
});
}
Ruft man watchConnection über die Lebensdauer der App 10.000-mal auf, hält dbPool 10.000 Listener, die jeweils ihr eigenes connection-Objekt einschließen. Die Lösung: den Listener entfernen, wenn man damit fertig ist (emitter.off(event, handler)), oder emitter.once() verwenden, wenn er nur einmal feuern soll.
Timer, die nie gelöscht werden. setInterval hält seine Closure – und alles, worauf sie verweist – so lange am Leben, wie der Timer läuft, was standardmäßig für immer ist.
// leaks if `session` objects are meant to be short-lived
function startHeartbeat(session) {
setInterval(() => {
session.lastSeen = Date.now();
}, 30_000);
// no reference to the interval kept anywhere — it can never be cancelled
}
Behält man den Rückgabewert von setInterval/setTimeout nicht, hat man das einzige Handle verloren, mit dem man clearInterval/clearTimeout später aufrufen könnte – der Timer und seine Closure leben, bis der Prozess endet.
Closures über große Objekte, obwohl nur ein kleines Stück gebraucht wird. Eine Funktion, die über ein ganzes Request- oder Response-Objekt schließt, nur um ein Feld zu nutzen, hält das gesamte Objekt (und alles, worauf es verweist) am Leben, solange die Closure existiert.
// keeps the entire `req` object alive inside `processedLater`
function schedule(req) {
const processedLater = () => console.log(req.user.id);
queue.push(processedLater);
}
// only keeps the one value you actually need
function schedule(req) {
const userId = req.user.id;
const processedLater = () => console.log(userId);
queue.push(processedLater);
}
Bestätigen, dass es wirklich ein Leck ist und nicht nur normales GC-Sägezahnmuster
Gesunder Node-Speicherverbrauch ist nicht flach – er geht auf und ab, während der GC läuft. Ein Leck ist ein Trend: Die Basislinie nach jedem GC-Zyklus steigt über die Zeit weiter, nicht nur die Spitzen zwischen den Zyklen.
setInterval(() => {
const { heapUsed, rss } = process.memoryUsage();
console.log(`heapUsed=${(heapUsed / 1024 / 1024).toFixed(1)}MB rss=${(rss / 1024 / 1024).toFixed(1)}MB`);
}, 60_000);
Protokolliere das ein paar Stunden unter echtem Traffic. Steigt heapUsed nach jedem Einbruch weiter an, hast du ein Leck; pendelt es um einen stabilen Bereich, ist das normales GC-Verhalten.
Das tatsächlich leckende Objekt mit Heap-Snapshots finden
Weiß man, dass es ein Leck gibt, ist das Werkzeug, um herauszufinden, was es ist, --inspect plus der Heap-Profiler der Chrome DevTools, mit einem Vergleich zweier Snapshots, die im Abstand von Minuten unter Last aufgenommen wurden:
node --inspect server.js
Öffne chrome://inspect in Chrome, verbinde dich mit dem Prozess und gehe im Memory-Tab so vor:
- Einen Heap-Snapshot aufnehmen (Baseline).
- Die App ein paar Minuten unter realistischer Last laufen lassen.
- Einen zweiten Snapshot aufnehmen.
- Die Ansicht auf „Comparison" zwischen beiden umschalten – das sortiert Objekte danach, wie viele neue Instanzen hinzugekommen und nicht eingesammelt worden sind.
Der Objekttyp ganz oben in diesem Vergleich – meist etwas verdächtig Spezifisches, wie deine eigene Session-Klasse oder eine ständig wachsende Closure-Anzahl – ist fast immer das Leck. Von dort zeigt der Retainer-Baum der DevTools, warum es noch erreichbar ist: welche Variable, welches Array oder welche Map die Referenz hält, die das Einsammeln verhindert. Dieser Retainer-Pfad ist die eigentliche Antwort – er sagt dir genau, welche Codezeile du reparieren musst.
Ein günstigerer erster Versuch: --max-old-space-size und Snapshots beim Absturz
Lässt sich das Leck in einer Debugger-Sitzung nicht leicht reproduzieren, bekommt man automatisch zum OOM-Zeitpunkt einen Heap-Snapshot, wenn man startet mit:
node --max-old-space-size=512 --heapsnapshot-near-heap-limit=3 server.js
Das schreibt bis zu 3 Heap-Snapshots auf die Platte, kurz bevor der Prozess sonst wegen der erreichten Speichergrenze abstürzen würde – nützlich in Produktion oder Staging, wo man live keinen Debugger anhängen kann, aber die resultierende .heapsnapshot-Datei später in die DevTools laden und für dieselbe Vergleichsanalyse nutzen kann.
Fazit
Jedes Node-Speicherleck lässt sich auf dieselbe Ursache zurückführen: Etwas hält noch eine Referenz, die der Code nicht mehr braucht. Caches ohne Verdrängung, Listener ohne Entfernung, Timer ohne Löschung und Closures über mehr, als sie brauchen, sind die vier Orte, an denen diese Referenz fast immer steckt. Bestätige den Trend mit process.memoryUsage() über die Zeit und finde dann das konkrete Objekt mit einem Heap-Snapshot-Vergleich – allein aus den Symptomen zu raten zeigt selten auf die richtige Codezeile.