Node Memory Leaks: Detached Cache Graphs That Heap Snapshots Reveal

Node Memory Leaks: Detached Cache Graphs That Heap Snapshots Reveal

Browser engineers talk about detached DOM trees; Node engineers get the same disease as Map/Set/closure graphs that outlive every request. RSS charts tell you something is wrong; heap snapshots tell you which retainer still points at yesterday’s traffic. The unfair advantage is a disciplined method: reproduce with a stable load, capture two snapshots, diff retained size by constructor, then fix with bounded caches, WeakRef/FinalizationRegistry only where semantics allow, and tests that fail on growth.

⚡ TL;DR: Diff heap snapshots under repeatable load; hunt growing (string), Object, Map, Closure retainers; replace unbounded Map caches with LRU + TTL; never store sockets/req objects in module-global sets. Pair with Node Heap Snapshots in Production and AsyncLocalStorage Overhead.

Reproduce, capture, diff

// scripts/leak-repro.ts
import { writeHeapSnapshot } from "node:v8";
import { app } from "../src/app.js";

async function burst() {
  for (let i = 0; i < 5_000; i++) {
    await app.inject({ method: "GET", url: `/items/${i % 200}` });
  }
  global.gc?.();
}

await burst();
writeHeapSnapshot("/tmp/before.heapsnapshot");
await burst();
await burst();
global.gc?.();
writeHeapSnapshot("/tmp/after.heapsnapshot");
// Diff in Chrome DevTools → Comparison → Rank by Retained Size delta
// ❌ Debugging only with process.memoryUsage().rss in Grafana
// RSS moves for many reasons — allocator caches, fragmentation — not just leaks

The usual Node retainers

// src/bad-cache.ts
const cache = new Map<string, object>(); // ❌ unbounded

export function getUser(id: string) {
  if (!cache.has(id)) cache.set(id, loadUser(id));
  return cache.get(id);
}

// src/good-cache.ts
import { LRUCache } from "lru-cache";

const cache = new LRUCache<string, object>({
  max: 5_000,
  ttl: 60_000,
  // ✅ updateAgeOnGet only if product wants sliding TTL
});
// EventEmitter / ALS traps
const sockets = new Set<Socket>();
server.on("connection", (s) => {
  sockets.add(s);
  // ❌ missing sockets.delete on close → classic growth
  s.on("close", () => sockets.delete(s)); // ✅
});

WeakRef is not a free lunch

// Use WeakRef only when values may disappear and you can recompute
const weakUsers = new Map<string, WeakRef<object>>();
const registry = new FinalizationRegistry((id: string) => weakUsers.delete(id));

export function remember(id: string, obj: object) {
  weakUsers.set(id, new WeakRef(obj));
  registry.register(obj, id);
}
// ✅ Still bound the Map of WeakRefs by key count — WeakRef doesn't shrink the key set

Closing checklist

✅ Dos
– ✅ Diff two snapshots under identical load with --expose-gc
– ✅ Bound every process-global cache (max + TTL)
– ✅ Remove listeners/sockets from module-level collections on close
– ✅ Add a CI soak that fails on retained-size growth for suspect caches
– ✅ Capture production snaps only via the gated path

❌ Don’ts
– ❌ Don’t trust RSS alone to name the leak
– ❌ Don’t use unbounded Map keyed by user/request IDs
– ❌ Don’t expect WeakRef to fix a Map that retains keys forever
– ❌ Don’t keep req/res in closures scheduled past the request lifetime

Related reading

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply