Zum Inhalt springen

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.

3 Min. Lesezeit
Node.js-Heap-Speicherdiagramm mit einem allmählichen Anstieg durch ein Memory Leak

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.

tstypescript
// ❌ 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 data

Die fünf häufigsten Leak-Muster

1. Event-Listener, die nie entfernt werden

tstypescript
// ❌ 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

tstypescript
// ❌ 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

tstypescript
// ❌ 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

tstypescript
// ❌ 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

tstypescript
// ❌ 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.

tstypescript
// 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" });
});
shbash
# 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 leaks

Heap-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.

tstypescript
// 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

  1. Event-Listener sind die Leak-Quelle Nr. 1 — entferne sie immer, sobald der Consumer die Verbindung trennt
  2. Closures erfassen mehr, als man denkt — achte genau darauf, welche Referenzen gehalten werden
  3. Caches brauchen eine Eviction-Policy — nutze LRU mit maximaler Größe und TTL, niemals unbegrenzte Maps
  4. Alle Timer löschen — speichere Intervall-/Timeout-IDs und lösche sie beim Aufräumen
  5. Heap Snapshots sind dein Diagnosewerkzeug — vergleiche zwei Snapshots, um zurückgehaltene Objekte zu finden
  6. Beobachte die Trends der Heap-Nutzung — eine ansteigende Basislinie zwischen GC-Zyklen deutet auf ein Leak hin
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX