Skip to content

V8 & Memory

V8’s garbage collector does not treat every object the same way. It splits the heap into spaces, and the two that matter for everyday reasoning are new_space (the young generation) and old_space (the old generation) — these are the exact names v8.getHeapSpaceStatistics() reports on Node 24.13:

flowchart LR
Alloc["new object
allocated"] --> New["new_space
young generation"]
New -->|"Scavenge
survives one collection"| New
New -->|"survives a few Scavenges"| Old["old_space
old generation"]
Old -->|"Mark-Compact
(mark-sweep-compact)"| Old
Where an object lives as it survives collections

Most objects die young — a temporary array, a request-scoped object — and V8 optimizes for that: a Scavenge collection runs frequently on the small new_space and is cheap, because most of what it looks at is already garbage. An object that survives a few Scavenges gets promoted to old_space, which is collected by a Mark-Compact (mark-sweep-compact) pass — slower, because it has to trace live objects across a much larger space, but it runs far less often. This is why a leak that only ever touches short-lived objects is rare; a real leak almost always means something is holding a reference that lets objects survive into old_space and never get freed there.

Every Node process exposes its current memory footprint directly:

Node.js
Output (verified on Node 24)
rss (total OS memory for this process, MB): 40
heapTotal (V8 heap currently reserved, MB): 10
heapUsed (V8 heap currently live, MB): 0
external (C++ objects bound to JS, e.g. some Buffers, MB): true
arrayBuffers (a subset of external, MB): true

rss (resident set size) is the whole process as the OS sees it — V8 heap, native code, and everything else. heapTotal and heapUsed are V8-managed JavaScript memory only. external and arrayBuffers are the important pair for anyone working with binary data: Buffer memory is allocated off the V8 heap and shows up here, not in heapUsed — a script that only allocates large Buffers can have a tiny heapUsed and a large rss, and only external/arrayBuffers will show it.

These are not hypothetical. Each one is a shape AI-written code produces constantly, and each is measurable with nothing but process.memoryUsage().

1. A closure over a growing array. A counter, cache, or logger that captures an array in its closure and only ever pushes to it:

Node.js
Output (verified on Node 24)
heapUsed before (MB): 0
heapUsed after 500,000 tracked events (MB): 140
grew by well over 10 MB with nothing ever freed: true

The fix is almost always a size cap or a TTL: cap history.length and drop the oldest entries, or replace it with something bounded on purpose (a ring buffer, or a proper metrics library).

2. A listener added per request, never removed. EventEmitter warns about this specifically:

Node.js
Output (verified on Node 24)
listener count after 15 simulated requests: 15
EventEmitter default max listeners: 10
MaxListenersExceededWarning fired: true

MaxListenersExceededWarning at more than 10 listeners for one event is not noise to silence with setMaxListeners(0) — it is Node telling you it suspects exactly this leak shape. The fix is to register the listener once (outside the per-request path) or to call emitter.off()/removeListener() when the request ends.

3. An unbounded Map used as a cache. Every distinct key becomes a permanent entry:

Node.js
Output (verified on Node 24)
cache size: 200000
heapUsed before (MB): 0
heapUsed after (MB): 100

A cache with no bound is a leak with extra steps. The fix is a real cache: an LRU with a maximum size, a TTL, or (when the cache is only ever a performance optimization and the values are otherwise reachable) a WeakMap keyed by object identity so entries disappear on their own once nothing else references the key.

WeakRef, FinalizationRegistry, and WeakMap

Section titled “WeakRef, FinalizationRegistry, and WeakMap”

All three exist so a cache or lookup table can hold a reference without being the reason an object stays alive:

Node.js
Output (verified on Node 24)
deref while still referenced elsewhere: cached value
typeof deref after dropping the strong reference: object

Note the second line: right after dropping the reference, the object is very likely not yet collected (GC runs on its own schedule), so deref() can still return it — typeof stays 'object' either way (an actual collection makes it 'undefined' instead). That non-determinism is the entire point: WeakRef and FinalizationRegistry (which runs a cleanup callback sometime after an object is collected, with no timing guarantee) are correct only for optional, best-effort cleanup — never for anything your program’s correctness depends on. WeakMap/WeakSet are the common case that avoids the ambiguity entirely: use one directly as a cache keyed by object identity, and let entries disappear when nothing else holds the key, with no manual WeakRef bookkeeping at all.

Constraining the heap on purpose is how you find a leak before production does. Running the closure-leak pattern above, but forever, against a tiny heap limit:

Terminal window
node --max-old-space-size=48 leaky-forever.js
<--- Last few GCs --->
[pid] Mark-Compact (reduce) ~38 (46) -> ~38 (46) MB, external memory pressure; GC in old space requested
[pid] Mark-Compact (reduce) ~38 (46) -> ~38 (46) MB, external memory pressure; GC in old space requested
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
----- Native stack trace -----
... (omitted -- V8/libuv internal frames)
$ echo $?
134

--max-old-space-size (in MB) caps old_space specifically. “Ineffective mark-compacts near heap limit” means V8 ran Mark-Compact repeatedly, freed almost nothing each time, and gave up rather than loop forever — that pattern (heap size barely changing across consecutive Mark-Compacts, right before the crash) is the signature of a real leak, as opposed to a process that is merely using a lot of memory for legitimate reasons.

--heap-prof writes a .heapprofile file (loadable in Chrome DevTools’ Memory tab) sampling allocations over the run, which is how you go from “there is a leak” to “this function is where the growing allocations come from”:

Terminal window
node --heap-prof allocate-a-lot.js
ls *.heapprofile
Heap.<timestamp>.<pid>.0.001.heapprofile

Picture the heap as two rooms. The front room (new_space) is small and gets swept clean constantly — most things dropped there are trash by the time the sweep happens, so sweeping it is fast. Anything that survives a few sweeps gets carried to the back room (old_space), which is only cleaned occasionally, with a slower, more thorough pass that has to check every item against a much bigger inventory. A leak is not “too much stuff in the front room” — it is something in the back room that a legitimate part of the program still holds a reference to, so the thorough cleaning pass correctly leaves it alone every single time.

Generational GC is a bet: most objects die young, so paying almost nothing to check the young generation constantly, and paying more to check the old generation rarely, wins on average. It loses when a program’s actual allocation pattern doesn’t match that bet — e.g., something that allocates huge numbers of medium-lived objects can end up promoting far more than typical, making Mark-Compact run more often than it would for a more typical workload. --max-old-space-size trades a hard ceiling (predictable failure mode: an OOM crash you can catch in monitoring) for the risk of crashing a process that might have recovered on its own; leaving it at the default trades that predictability for a process that can, in principle, grow until the OS kills it for a much less specific reason.

  • “heapUsed is the process’s total memory usage.” It is V8’s JS heap only. Buffers and other off-heap C++-backed data show up in external/arrayBuffers; the OS-level total is rss. A process that only allocates large Buffers can look fine by heapUsed and still be consuming a lot of memory.
  • “WeakRef guarantees the object gets collected.” It guarantees the opposite direction only: holding a WeakRef never prevents collection. Whether and when collection actually happens is entirely up to the GC’s own schedule.
  • “A bigger --max-old-space-size fixes a memory leak.” It only delays the crash — the growth rate is unchanged, so a real leak still reaches any ceiling eventually. The right fix is finding what is holding the reference, not raising the ceiling.

Generated caching code is a frequent source of this bug class, because “cache the result” is a common pattern to generate and “bound the cache” is easy to omit without the code looking wrong.

🐛 Spot the bug

// AI-written response cache for an API client
const responseCache = new Map();
async function getWithCache(url) {
if (responseCache.has(url)) return responseCache.get(url);
const data = await fetch(url).then((r) => r.json());
responseCache.set(url, data);
return data;
}
Show the answer

There is no eviction anywhere: responseCache grows by one entry per distinct URL ever requested, for the lifetime of the process, with no size limit and no TTL. For a long-running server hit with many distinct URLs (including ones with query strings or user IDs baked in), this is the “unbounded Map cache” leak pattern from this lesson, verified above to grow into the hundreds of megabytes at real scale. The fix is a bounded cache — an LRU with a maximum entry count, a TTL per entry, or, if staleness is unacceptable, no cache at all rather than an unbounded one.

Which V8 heap space does a newly allocated, short-lived object start in?
A script runs this in a loop and never touches regular JS objects: What would you expect from process.memoryUsage()?
const buf = Buffer.allocUnsafe(10 * 1024 * 1024); // 10 MB, repeated many times
What does "Ineffective mark-compacts near heap limit" in an OOM crash indicate?
Why does --max-old-space-size not fix a memory leak, only mask it?
This AI-written cache never removes entries: What is the correct fix?
const responseCache = new Map();
async function getWithCache(url) {
  if (responseCache.has(url)) return responseCache.get(url);
  const data = await fetch(url).then((r) => r.json());
  responseCache.set(url, data);
  return data;
}