Memory Management

Node.js Advanced — Memory Management

Node.js Advanced

Chapter 2 of 8  ·  Memory Management

Memory Management

Node hands memory management to V8 — you allocate objects, V8 decides when to collect them. Most of the time this just works. But long-running servers accumulate objects that should have been freed, causing the heap to grow until the process runs out of memory or becomes so slow under GC pressure that it effectively stops serving requests. Understanding how V8 manages memory lets you write code that doesn't fight the garbage collector — and diagnose it when something leaks.

How V8 Organises the Heap

V8 Heap Layout ┌─────────────────────────────────────────────────────────────┐ │ V8 Heap │ │ │ │ ┌──────────────────────┐ ┌───────────────────────────┐ │ │ │ New Space (~8 MB) │ │ Old Space (~1.4 GB) │ │ │ │ (semi-space × 2) │ │ │ │ │ │ │ │ Objects that survived │ │ │ │ Short-lived objs │──►│ ≥2 minor GC cycles │ │ │ │ Minor GC: fast │ │ Major GC: slow │ │ │ │ (Scavenge) │ │ (Mark-Sweep-Compact) │ │ │ └──────────────────────┘ └───────────────────────────┘ │ │ │ │ ┌──────────────────┐ ┌─────────────┐ ┌──────────────┐ │ │ │ Code Space │ │ Large Object │ │ Map Space │ │ │ │ JIT-compiled fn │ │ Space (>256K)│ │ Hidden cls │ │ │ └──────────────────┘ └─────────────┘ └──────────────┘ │ └─────────────────────────────────────────────────────────────┘

The key insight is generational collection: most objects die young (short-lived request data, temporary buffers). V8 runs a fast minor GC (Scavenge) on the small new space frequently. Objects that survive get promoted to old space, where the slower major GC (Mark-Sweep-Compact) runs less often but pauses longer.

Monitoring Memory and GC

// process.memoryUsage() — snapshot of current memory use const mem = process.memoryUsage(); console.log({ rss: mb(mem.rss), // total process memory (RSS) heapTotal: mb(mem.heapTotal), // heap capacity V8 has reserved heapUsed: mb(mem.heapUsed), // heap actually in use ← watch this one external: mb(mem.external), // C++ objects linked to JS (Buffers) arrayBuffers: mb(mem.arrayBuffers), // ArrayBuffers + SharedArrayBuffers }); function mb(bytes) { return (bytes / 1024 / 1024).toFixed(1) + ' MB'; }
// Monitor GC events with PerformanceObserver const { PerformanceObserver } = require('node:perf_hooks'); const obs = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { const kind = entry.detail?.kind; const kindName = { 1: 'Minor (Scavenge)', 2: 'Major (Mark-Sweep)', 4: 'Incremental Marking', 8: 'Weak Callback', }[kind] ?? `kind=${kind}`; console.log(`GC [${kindName}] ${entry.duration.toFixed(2)} ms`); } }); obs.observe({ entryTypes: ['gc'] });
// Heap limit — default is ~1.4 GB on 64-bit; raise it for memory-hungry apps // node --max-old-space-size=4096 server.js (4 GB) // v8.getHeapStatistics() — detailed breakdown const v8 = require('node:v8'); const stats = v8.getHeapStatistics(); console.log('Heap limit:', mb(stats.heap_size_limit)); console.log('Used:', mb(stats.used_heap_size)); console.log('Available:', mb(stats.heap_size_limit - stats.used_heap_size));

The Six Most Common Memory Leaks

1 — Growing Globals

// LEAK: pushing to a global array that's never pruned const requestLog = []; app.use((req, _res, next) => { requestLog.push({ url: req.url, time: new Date() }); // grows forever next(); }); // FIX: cap the array const MAX_LOG = 1000; app.use((req, _res, next) => { requestLog.push({ url: req.url, time: new Date() }); if (requestLog.length > MAX_LOG) requestLog.shift(); next(); });

2 — Forgotten Event Listeners

// LEAK: adding a listener on every request without removing it app.get('/subscribe', (req, res) => { emitter.on('data', (data) => res.write(data)); // new listener per request — never removed }); // FIX: remove the listener when the connection closes app.get('/subscribe', (req, res) => { const handler = (data) => res.write(data); emitter.on('data', handler); req.on('close', () => emitter.off('data', handler)); // clean up on disconnect });

3 — Timers Holding Closures

// LEAK: interval closure references a large object; interval is never cleared function startMonitor(heavyObject) { const id = setInterval(() => { console.log(heavyObject.status); // keeps heavyObject alive forever }, 1000); // id is never stored — can never be cleared } // FIX: return the id so the caller can stop it function startMonitor(heavyObject) { const id = setInterval(() => console.log(heavyObject.status), 1000); return () => clearInterval(id); // caller calls stop() when done } const stop = startMonitor(thing); // ... later: stop();

4 — Unbounded Caches (Maps and Objects)

// LEAK: cache grows without limit const cache = new Map(); function getUser(id) { if (!cache.has(id)) cache.set(id, fetchFromDB(id)); return cache.get(id); // every unique id stays in memory forever } // FIX A: TTL-based expiry const cache = new Map(); // key → { value, expiresAt } function getUser(id) { const hit = cache.get(id); if (hit && Date.now() < hit.expiresAt) return hit.value; const value = fetchFromDB(id); cache.set(id, { value, expiresAt: Date.now() + 60_000 }); return value; } // FIX B: LRU eviction — use 'lru-cache' package (Chapter 5) const { LRUCache } = require('lru-cache'); const cache = new LRUCache({ max: 500, ttl: 60_000 });

5 — Closures Capturing Large Scope

// LEAK: the callback only needs 'id', but its closure retains the entire 'data' object function processRequest(data) { const bigBuffer = new Buffer.alloc(1024 * 1024); // 1 MB doAsyncWork().then(() => { console.log(data.id); // closure keeps data (and bigBuffer!) alive }); } // FIX: extract only what you need before the async boundary function processRequest(data) { const id = data.id; // copy the primitive const bigBuffer = new Buffer.alloc(1024 * 1024); doAsyncWork().then(() => { console.log(id); // only 'id' is retained — bigBuffer can be GC'd }); }

6 — Accumulating Promises

// LEAK: fire-and-forget loop with no back-pressure — queues millions of Promises for (const item of millionItems) { processItem(item); // no await — all Promises queued simultaneously } // FIX: limit concurrency with a pool async function runConcurrent(items, fn, concurrency = 10) { const pool = new Set(); for (const item of items) { const p = fn(item).finally(() => pool.delete(p)); pool.add(p); if (pool.size >= concurrency) await Promise.race(pool); } await Promise.all(pool); }

WeakRef — References That Don't Prevent GC

A normal object reference tells the GC "keep this alive." A WeakRef says "I'd like to use this if it's still around, but don't keep it alive on my account." The primary use case is optional caches: if memory is tight, the GC can collect the cached value and you fall back to recomputing it.

let heavyObject = { data: new Array(1_000_000).fill(0) }; // WeakRef holds a reference that doesn't prevent GC const ref = new WeakRef(heavyObject); // Dereference — returns the object or undefined if it was collected const obj = ref.deref(); if (obj) { console.log('Object still alive:', obj.data.length); } else { console.log('Object was garbage collected'); } // Classic WeakRef cache pattern class WeakCache { constructor() { this._map = new Map(); } get(key) { const ref = this._map.get(key); if (!ref) return undefined; const value = ref.deref(); if (!value) this._map.delete(key); // clean up dead reference return value; } set(key, value) { this._map.set(key, new WeakRef(value)); } }

FinalizationRegistry — Cleanup Callbacks

FinalizationRegistry lets you register a callback that fires after an object is garbage collected. Useful for releasing external resources (file handles, native pointers) tied to a JavaScript object, or for cleaning up Map entries when their keys are collected.

const registry = new FinalizationRegistry((heldValue) => { // Called after the registered object is collected // heldValue is what you passed as the third argument to register() console.log(`Cleaned up: ${heldValue}`); releaseNativeResource(heldValue); }); function createHandle(name) { const handle = { name, data: new Array(10_000) }; registry.register(handle, name); // register(target, heldValue) return handle; }

⚠️ WeakRef and FinalizationRegistry Caveats

GC timing is non-deterministic. You cannot predict or control when the collector runs. Don't use WeakRef for logic that must happen promptly — use explicit lifecycle methods (dispose(), close()) for that.

FinalizationRegistry callbacks may never fire in short-lived processes or if the process exits before GC runs. Never rely on them for critical cleanup.

The TC39 using keyword (explicit resource management, Node 20+) is a better pattern for deterministic cleanup — it's a try/finally sugar that calls [Symbol.dispose]() when the block exits.

WeakMap and WeakSet — Object-Keyed Weak Collections

WeakMap keys must be objects, and holding a key in a WeakMap does not prevent that object from being collected. This makes WeakMap the ideal place to store metadata about objects you don't own — the metadata disappears automatically when the object does.

// Attach private metadata to objects without leaking memory const metadata = new WeakMap(); function processUser(user) { metadata.set(user, { processedAt: Date.now(), flags: [] }); } function getFlags(user) { return metadata.get(user)?.flags ?? []; } // When 'user' is no longer referenced elsewhere, the WeakMap entry // and its metadata are collected automatically — no manual cleanup needed. // WeakSet — track a set of objects without preventing their collection const seen = new WeakSet(); function processOnce(obj) { if (seen.has(obj)) return; seen.add(obj); doWork(obj); }
CollectionKeys/valuesPrevents GC?Iterable?Use for
Map Any type Yes Yes General-purpose key→value store; use when you need to enumerate
WeakMap Object keys only No (keys) No Metadata on objects you don't own; private class fields alternative
Set Any type Yes Yes Unique value collection; deduplication
WeakSet Objects only No No Tracking "has this object been seen?" without preventing its collection

Diagnosing a Leak — The Workflow

# 1. Watch heapUsed over time — a leak looks like a one-way upward trend node --max-old-space-size=512 server.js # 2. Generate load while watching memory autocannon -c 50 -d 60 http://localhost:3000/ # 3. Take snapshot 1 (before) kill -USR2 <pid> # if you wired up SIGUSR2 → v8.writeHeapSnapshot() # 4. Generate more load, then take snapshot 2 (after) kill -USR2 <pid> # 5. Load both .heapsnapshot files in Chrome DevTools → Memory tab # Switch to "Comparison" view # Sort by "# Delta" (objects added) — the top entries are the leaking types # Click into a type, follow "Retainers" to see what's holding the reference

💡 Forcing GC in Tests

Start Node with --expose-gc to unlock global.gc(), which runs a full GC cycle on demand. Useful in tests that verify something was actually freed:

node --expose-gc test.js

let obj = { big: new Array(1_000_000) };
const ref = new WeakRef(obj);
obj = null; // remove the strong reference
global.gc(); // force collection
console.log(ref.deref()); // undefined — collected

Never ship --expose-gc in production — forcing GC at the wrong time stalls the event loop.

🔴 Signs Your App Has a Memory Leak

heapUsed never comes back down after load drops. Healthy apps have a sawtooth pattern (up under load, GC brings it back down). A leak shows a ratchet pattern — GC runs but reclaims less and less each time.

Old-space GC pauses getting longer and more frequent. As the heap fills, the major GC has to do more work each cycle.

The process eventually OOMs or PM2 restarts it due to exceeding max_memory_restart. Memory leaks in production usually manifest as slow overnight growth, then an OOM restart in the early hours.

Coding Challenges

Challenge 1 — Memory Leak Hunter

The file below contains three subtle memory leaks. Find and fix all three. For each fix, write a comment explaining what was leaking and why your fix resolves it. Then add a memoryUsage() logging loop that prints heapUsed every 2 seconds so you can verify the heap stabilises after your fixes rather than growing indefinitely.

View sample solution ↗

Challenge 2 — WeakRef Cache with FinalizationRegistry

Build a SoftCache class backed by WeakRefs that: stores values keyed by string, automatically removes stale Map entries when values are collected (using FinalizationRegistry), exposes get(key), set(key, value), has(key), and size (count of live entries only). Write a test using --expose-gc that proves entries disappear from size after the external reference is dropped and global.gc() is called.

View sample solution ↗

Challenge 3 — Bounded Concurrent Processor

Write an async processAll(items, fn, { concurrency, onProgress }) function that processes an array with bounded parallelism (no more than concurrency Promises alive at once), calls onProgress({ done, total, heapUsedMB }) after each item completes, and returns all results in the original order. Demonstrate that heap usage stays flat when processing 10,000 items with concurrency 20, compared to firing all 10,000 Promises simultaneously.

View sample solution ↗