Memory Leaks in Node.js: Erkennung und Vermeidung
Closures, Event-Listener und nicht bereinigte Timer sind die häufigsten Memory-Leaks in Node.js — so findest du sie vor dem Produktionsausfall.

Memory Leaks in Node.js führen nicht sofort zum Absturz. Sie sind schleichend: Der Speicherverbrauch steigt über Stunden oder Tage, bis dem Prozess der Heap-Speicher ausgeht und er mit einem kryptischen OOM-Fehler stirbt. Die Ursache ist meist banal: ein Event-Listener, der nie entfernt wird, ein Closure, das eine Referenz länger als nötig hält, oder ein Cache, der unbegrenzt wächst.
Wie der Speicher in Node.js funktioniert
V8 (die JavaScript-Engine von Node) verwendet einen generationellen Garbage Collector. Objekte im „new space" werden schnell eingesammelt. Objekte, die mehrere GC-Zyklen überstehen, werden in den „old space" befördert, der seltener und aufwendiger bereinigt wird.
Ein Memory Leak entsteht, wenn Objekte im old space nicht mehr gebraucht, aber weiterhin referenziert werden — der Garbage Collector kann sie dann nicht freigeben.
// ❌ Global array that grows without bounds
const requestLog: object[] = [];
app.use((req, res, next) => {
requestLog.push({
url: req.url,
headers: req.headers,
timestamp: Date.now(),
body: req.body, // Large objects accumulate
});
next();
});
// After 1M requests: requestLog holds gigabytes of dataDie fünf häufigsten Leak-Muster
1. Event-Listener, die nie entfernt werden
// ❌ Adding a listener on every request — never removed
app.get("/api/stream", (req, res) => {
const handler = (data: Buffer) => {
res.write(data);
};
dataSource.on("data", handler);
// If the client disconnects, the handler is never removed
// The closure holds a reference to `res`, preventing GC
});
// ✅ Clean up on close
app.get("/api/stream", (req, res) => {
const handler = (data: Buffer) => {
res.write(data);
};
dataSource.on("data", handler);
req.on("close", () => {
dataSource.removeListener("data", handler);
});
});2. Closures mit großen Referenzen
// ❌ Closure captures the entire large object
function processLargeDataset(data: Buffer) {
const processed = expensiveTransform(data);
return function getResult() {
// This closure holds a reference to `data` (the entire Buffer)
// even though it only needs `processed`
return processed;
};
}
// ✅ Let the large reference go out of scope
function processLargeDataset(data: Buffer) {
const processed = expensiveTransform(data);
// `data` reference is not captured by the returned function
return function getResult() {
return processed;
};
}3. Unbegrenzte Caches
// ❌ Map that only grows — no eviction policy
const cache = new Map<string, unknown>();
function getCached(key: string, fetcher: () => Promise<unknown>) {
if (cache.has(key)) return cache.get(key);
const value = fetcher();
cache.set(key, value);
return value;
}
// ✅ LRU cache with a maximum size
import { LRUCache } from "lru-cache";
const cache = new LRUCache<string, unknown>({
max: 500, // Maximum 500 entries
ttl: 1000 * 60 * 5, // 5-minute TTL
});4. Timer und Intervalle, die nie gelöscht werden
// ❌ setInterval inside a handler — never cleared
class DataPoller {
start() {
setInterval(async () => {
const data = await fetchLatestData();
this.processData(data);
}, 5000);
}
// If this object is "discarded" but the interval still runs,
// it prevents the entire object from being garbage collected
}
// ✅ Store interval ID and clear on cleanup
class DataPoller {
private intervalId: NodeJS.Timeout | null = null;
start() {
this.intervalId = setInterval(async () => {
const data = await fetchLatestData();
this.processData(data);
}, 5000);
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
}5. Promises, die nie aufgelöst werden
// ❌ Promises that hang forever hold their closure in memory
function waitForEvent(emitter: EventEmitter, event: string) {
return new Promise((resolve) => {
emitter.once(event, resolve);
// If the event never fires, this Promise (and its closure) lives forever
});
}
// ✅ Add a timeout
function waitForEvent(emitter: EventEmitter, event: string, timeoutMs = 30000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
emitter.removeListener(event, onEvent);
reject(new Error(`Timeout waiting for ${event}`));
}, timeoutMs);
function onEvent(data: unknown) {
clearTimeout(timer);
resolve(data);
}
emitter.once(event, onEvent);
});
}Erkennung: Heap Snapshots
Mit dem eingebauten Inspector von V8 kannst du Heap Snapshots erstellen und im zeitlichen Verlauf vergleichen.
// Expose a diagnostic endpoint (behind auth!)
import v8 from "v8";
import fs from "fs";
app.get("/debug/heap-snapshot", requireInternalAuth, (req, res) => {
const filename = `/tmp/heap-${Date.now()}.heapsnapshot`;
const snapshotStream = v8.writeHeapSnapshot(filename);
res.json({ file: snapshotStream, message: "Heap snapshot written" });
});# Take two snapshots 10 minutes apart
# Open both in Chrome DevTools (Memory tab)
# Compare: objects that exist in snapshot 2 but not snapshot 1 are potential leaksHeap-Nutzung überwachen
Verfolge die Heap-Nutzung über die Zeit. Ein gesunder Prozess zeigt ein Sägezahnmuster (Speicher steigt, GC läuft, Speicher sinkt). Ein Leak zeigt sich als stetig ansteigende Basislinie.
// Log memory metrics every 30 seconds
setInterval(() => {
const usage = process.memoryUsage();
console.log({
heapUsed: Math.round(usage.heapUsed / 1024 / 1024),
heapTotal: Math.round(usage.heapTotal / 1024 / 1024),
rss: Math.round(usage.rss / 1024 / 1024),
external: Math.round(usage.external / 1024 / 1024),
});
}, 30000);Die wichtigsten Erkenntnisse
- Event-Listener sind die Leak-Quelle Nr. 1 — entferne sie immer, sobald der Consumer die Verbindung trennt
- Closures erfassen mehr, als man denkt — achte genau darauf, welche Referenzen gehalten werden
- Caches brauchen eine Eviction-Policy — nutze LRU mit maximaler Größe und TTL, niemals unbegrenzte Maps
- Alle Timer löschen — speichere Intervall-/Timeout-IDs und lösche sie beim Aufräumen
- Heap Snapshots sind dein Diagnosewerkzeug — vergleiche zwei Snapshots, um zurückgehaltene Objekte zu finden
- Beobachte die Trends der Heap-Nutzung — eine ansteigende Basislinie zwischen GC-Zyklen deutet auf ein Leak hin


