Skip to main content
Why Node Processes Keep Growing: Field Notes on Heap Snapshots, Module-Scope Caches, and Leaks That Only Appear When Instances Are Reused

Why Node Processes Keep Growing: Field Notes on Heap Snapshots, Module-Scope Caches, and Leaks That Only Appear When Instances Are Reused

September 15, 2026
Full-Stack Engineering
8 min read

Key takeaways

• process.memoryUsage() returns rss, heapTotal, heapUsed, external, and arrayBuffers. Only heapUsed measured after a full garbage collection proves JavaScript objects are retained.

• A memory leak is a rising floor, not a rising line. heapUsed sawtooths under load and should return to the same baseline after every major garbage collection.

• Module scope is where Node.js servers leak. Anything declared outside a request handler — a Map, an array, an event listener, a setInterval — survives every request that process serves.

• The flag node --heapsnapshot-signal=SIGUSR2 writes a .heapsnapshot on demand, and the Chrome DevTools Memory panel Comparison view diffs two snapshots by object count and retained size.

• Serverless does not prevent leaks. Fluid Compute on Vercel reuses function instances across invocations, so module scope persists between requests exactly as on a long-lived server.

How do you tell a real memory leak from normal heap growth?

A real leak is a heapUsed floor that rises across garbage collections; normal growth is a sawtooth that returns to the same baseline. V8 allocates eagerly and collects lazily, so a heap that grew between two arbitrary samples proves nothing.

The five fields of process.memoryUsage() point at different failure modes. rss is resident set size, meaning every page the OS has mapped for the process, including native allocations. heapUsed is live JavaScript objects in the V8 heap. external and arrayBuffers cover memory owned by C++ objects bound to JavaScript, which is where Buffer instances live. A leak visible in external but not heapUsed is a Buffer or stream problem, not an object-graph problem.

Our team samples memoryUsage on a thirty-second interval and always calls .unref() on that timer, because an interval that keeps the event loop alive is itself a leak. For a definitive read we start the process with --expose-gc, call global.gc(), and sample immediately. That number is the floor, and the floor is the only line worth alerting on.

What actually leaks in a Node.js server?

Five patterns cover nearly every Node.js leak we have debugged, and all five live at module scope.

• An unbounded Map or object keyed by something request-shaped. A cache keyed by user id, request id, idempotency key, or session token has a key space that grows with traffic and never shrinks.

• Listeners added per request to a long-lived emitter. Node.js warns once at eleven listeners with "MaxListenersExceededWarning: Possible EventEmitter memory leak detected", then stays silent while the count climbs.

• Timers holding closures. A setInterval or unresolved setTimeout that captures a request object keeps that whole object graph reachable for the life of the timer.

• AsyncLocalStorage stores holding whole request objects. AsyncLocalStorage is the Node.js API for propagating per-request context through async calls, and the store stays reachable while any async operation in that context is pending.

• Retained buffers and stream chunks. Collecting response bodies into a module-level array shows up in external and rss while heapUsed stays flat, which is why it survives a heap-only investigation.

The leak we chased most recently was the first pattern: a memoization Map keyed by a per-request idempotency key, so every request added an entry nothing would ever read again.

How do you capture and compare a heap snapshot in production?

Start the process with --heapsnapshot-signal=SIGUSR2, send the signal twice with real traffic in between, and diff the two files. A heap snapshot is a point-in-time graph of every reachable object in the V8 heap, so one snapshot shows size and two snapshots show growth.

Three other capture paths are worth knowing. v8.writeHeapSnapshot() snapshots from inside the process, but it is synchronous and blocks the event loop for roughly the size of the heap, so it belongs on a guarded admin route. The flag --heapsnapshot-near-heap-limit=1 writes one automatically just before an out-of-memory crash, turning an unreproducible incident into an artifact. The flag --heap-prof runs the V8 sampling heap profiler and answers which call sites allocated the memory rather than what retains it now.

Load both files into Chrome DevTools under the Memory panel, switch to the Comparison view, and sort by size delta. Shallow size is the memory of the object itself; retained size is everything that becomes collectable when that object is freed, which is why retained size identifies the root of a leak. Select the suspect constructor and read the Retainers panel for the reference chain holding it alive.

Why do memory leaks only appear when serverless instances are reused?

A leak needs a process that lives long enough to accumulate, and instance reuse is what gives it one. Classic one-request-per-instance serverless hid leaks by accident, because the instance froze or was recycled before growth mattered.

Fluid Compute, the default execution model for Vercel Functions, reuses a function instance across concurrent and sequential invocations. That reuse is why a database connection pool declared at module scope works instead of opening a socket per request, and it is the same property that turns a module-scope Map into a leak. The mechanism you rely on for the singleton is the mechanism carrying the leak.

Local development has the inverse trap: hot module replacement re-evaluates modules on every edit, so the globalThis singleton pattern used for clients such as Prisma also keeps leaked state alive across reloads.

Which caches are safe to keep at module scope?

A module-scope cache is safe when its key space is bounded by something other than traffic.

• Map or plain object: no bound, grows forever. Use only when the key space is small and fixed, such as parsed config, compiled regexes, or a provider registry.

• WeakMap: the entry dies when its key object becomes unreachable. Use for a cache keyed by an object whose lifetime you do not control.

• lru-cache with max and ttl: a hard entry cap plus expiry. Use for any key derived from user input, such as a user id, tenant id, or token.

• React cache(): scoped to one server request. Use for deduping identical reads inside a single React Server Component render.

• Next.js Data Cache via "use cache": off-heap and revalidated. Use for data that should persist across requests and deploys.

WeakMap is narrower than it looks, because its keys must be objects or non-registered symbols. A cache keyed by a user id string cannot be a WeakMap and needs an explicit max instead. The test we use in code review is one line: if a cache key can be derived from user input, the cache needs a cap.

How do you catch a memory leak before it reaches production?

Run the same request thousands of times against one process, force a garbage collection, and assert that heapUsed did not grow past a budget. This is a regression test rather than a benchmark, and it runs on the Node.js built-in test runner with no extra dependency: warm up two hundred iterations, call global.gc(), record the baseline heapUsed, run two thousand more iterations, and assert the delta after a second global.gc() stays under a few megabytes.

Run that test with node --expose-gc --test, and assert a budget rather than zero growth, because V8 legitimately retains compiled code, inline caches, and interned strings after warmup, which is what the warmup loop absorbs.

Two production settings finish the job. Set --max-old-space-size below the container memory limit so V8 hits its own ceiling first and can write a snapshot, instead of the kernel OOM killer removing the process with no artifact. Then export rss and heapUsed as gauges and alert on the minimum over a multi-hour window, because peaks are traffic and a rising minimum is a leak.

FAQ

Q: Does growing rss always mean a memory leak?

A: No. rss includes native allocations, Buffer memory, and pages the allocator has not returned to the OS. Confirm with heapUsed sampled immediately after a forced garbage collection before calling it a leak.

Q: Can a serverless function leak memory?

A: Yes. Fluid Compute on Vercel reuses function instances across invocations, so anything held at module scope persists between requests and can grow without bound.

Q: Is WeakMap a general fix for cache leaks?

A: Only when the cache key is an object whose lifetime something else controls. WeakMap keys must be objects or non-registered symbols, so a cache keyed by a string id still needs an explicit bound such as lru-cache with max.

Q: Why was a process killed without a JavaScript heap error?

A: The container memory limit was reached before V8 reached its heap limit, so the kernel OOM killer terminated the process, typically with exit code 137. A V8 failure instead reads "FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory".