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,Closureretainers; replace unboundedMapcaches 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
- Node Heap Snapshots in Production: Safe Capture Under Incident Load
- AsyncLocalStorage Overhead: Measure Before Wrapping Every Request Path
- Event Loop Blockers: Perfetto Traces That Metrics Alone Will Miss
- Node Flamegraphs on ECS: Continuous Profiling That Survives Scale
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
