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
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
The Six Most Common Memory Leaks
1 — Growing Globals
2 — Forgotten Event Listeners
3 — Timers Holding Closures
4 — Unbounded Caches (Maps and Objects)
5 — Closures Capturing Large Scope
6 — Accumulating Promises
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.
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.
⚠️ 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.
| Collection | Keys/values | Prevents 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
💡 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.
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.
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.