Node Memory Leaks: Detached Cache Graphs That Heap Snapshots Reveal

Node Memory Leaks: Detached Cache Graphs That Heap Snapshots Reveal
10 views
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.

Node Memory Leaks: production guidance

typescript
// 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
typescript
// ❌ Debugging only with process.memoryUsage().rss in Grafana
// RSS moves for many reasons — allocator caches, fragmentation — not just leaks

The usual Node retainers

typescript
// 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
});
typescript
// 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

typescript
// 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 Related: Node.js Streams Backpressure: The Memory Leak That Kills Servers at 3AM; Node.js Streams: Process Gigabytes of Data Without Running Out of Memory; Node Module Compile Cache: Faster Cold Starts for Heavy CLIs.

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


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 comment

No account needed. Name and email are optional.