DEV Community

Ahmed Mahmoud
Ahmed Mahmoud

Posted on Originally published at devya.dev

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

Headline: A Node.js process whose memory climbs for hours is almost never leaking inside a request handler — it is leaking at module scope, where a reference outlives the request that created it. What found my leak was reading heapUsed after a forced garbage collection instead of watching rss, diffing two .heapsnapshot files in the Chrome DevTools Comparison view, and following the Retainers panel back to one Map I had declared outside a function.

I had a Node service that was healthy for about an hour after every deploy and then started climbing. No endpoint was slow and a restart always fixed it — the signature of state that lives above the request rather than inside it.

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.
  • 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 I 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 — 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.

setInterval(() => {
  const m = process.memoryUsage();
  console.log(JSON.stringify({
    rss: Math.round(m.rss / 1e6),
    heapUsed: Math.round(m.heapUsed / 1e6),
    external: Math.round(m.external / 1e6),
  }));
}, 30_000).unref();
Enter fullscreen mode Exit fullscreen mode

The .unref() matters: without it the interval keeps the event loop alive. For a definitive read, 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 I have chased, 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.

Mine was the first one: a memoization Map keyed by a per-request idempotency key, so every request added an entry nothing would ever read again.

How do I 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.

node --heapsnapshot-signal=SIGUSR2 --max-old-space-size=512 server.js

# after warmup, then again after load
kill -USR2 $(pgrep -f server.js)
Enter fullscreen mode Exit fullscreen mode

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. --heapsnapshot-near-heap-limit=1 writes one automatically just before an out-of-memory crash, turning an unreproducible incident into an artifact. --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: 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 like 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.

Structure Bound Use when
Map or plain object None — grows forever Key space is small and fixed: parsed config, compiled regexes, a provider registry
WeakMap Entry dies when the key object becomes unreachable Cache keyed by an object whose lifetime you do not control
lru-cache with max and ttl Hard entry cap plus expiry Any key derived from user input: user id, tenant id, token
React cache() One server request Deduping identical reads inside a single React Server Component render
Next.js Data Cache via "use cache" Off-heap, revalidated Data that should persist across requests and deploys

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

How do I 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, not a benchmark, and it runs on the Node.js built-in test runner with no extra dependency.

import test from 'node:test';
import assert from 'node:assert/strict';

test('handler does not retain memory across requests', async () => {
  for (let i = 0; i < 200; i++) await handle(makeRequest());
  global.gc();
  const baseline = process.memoryUsage().heapUsed;

  for (let i = 0; i < 2000; i++) await handle(makeRequest());
  global.gc();
  const grewMb = (process.memoryUsage().heapUsed - baseline) / 1e6;

  assert.ok(grewMb < 8, 'heap grew ' + grewMb.toFixed(1) + ' MB');
});
Enter fullscreen mode Exit fullscreen mode

Run it with node --expose-gc --test, and assert a budget rather than zero growth: 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 — 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 my 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.


Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.

Top comments (0)