Skip to content
5 min read

How to find a Node.js memory leak in production

Memory that climbs until the process restarts. How to tell a leak from a cache, take heap snapshots safely, and read the comparison that names the culprit.

On this page
  1. First, confirm it is a leak
  2. Two snapshots, with traffic in between
  3. The usual culprits
  4. Seatbelts are not fixes
  5. It is nearly always one line

The symptom always has the same shape. Memory climbs through the day, the process restarts itself overnight or gets killed by the orchestrator, and the graph settles into a sawtooth everyone has quietly agreed to stop looking at.

Nothing throws. Requests succeed. The only evidence is a line going up.

First, confirm it is a leak

Rising memory is not a leak. A Node process grows until the garbage collector decides collecting is worth the pause, and under load it will happily sit well above its idle size. That is the runtime working.

The test is whether it comes back down.

ts
setInterval(() => {
  const m = process.memoryUsage();
  console.log(JSON.stringify({
    rss: m.rss,              // everything the OS gave the process
    heapUsed: m.heapUsed,    // live JS objects — the number that matters here
    external: m.external,    // buffers and native allocations
  }));
}, 60_000);

Log that across a full traffic cycle, then compare the quiet-period baseline day over day. Healthy is a sawtooth returning to roughly the same floor. A leak is a staircase, where every quiet period settles a little higher than the one before.

One thing worth checking before you go further: if rss grows while heapUsed stays flat, this is not a JavaScript leak at all. That is buffers, native modules or fragmentation, and it is a completely different investigation that heap snapshots will not help with.

Two snapshots, with traffic in between

One heap snapshot tells you what is in memory, which is mostly what you would expect to be in memory. Two snapshots tell you what arrived and stayed, which is the actual question.

Expose a way to take them deliberately:

src/debug/heap.tsts
import v8 from "node:v8";
import path from "node:path";

// Triggered by a signal so it is never reachable over HTTP:
//   kill -USR2 <pid>
process.on("SIGUSR2", () => {
  const file = path.join("/tmp", `heap-${Date.now()}.heapsnapshot`);
  v8.writeHeapSnapshot(file);
  console.warn("heap snapshot written", file);
});

Then the procedure that finds things. Snapshot once the process is warm. Let it serve real traffic long enough that the staircase steps up, which is usually an hour or two. Snapshot again. Open both in Chrome DevTools under Memory, load each snapshot, and switch the view to Comparison.

Sort by Delta. You are looking for a constructor whose instance count grew by thousands and never came back. Expand it and read the Retainers panel, which is the chain of references keeping those objects alive. The bottom of that chain is nearly always the bug, and it is usually one line.

Two practical cautions, both learned the hard way. Writing a snapshot pauses the process, and the file can be as large as the heap. Do this on one instance pulled out of rotation, not across the fleet at once.

The usual culprits

A module-level collection with no bound, most of the time. This is the most common leak in any long-running Node service by a wide margin.

ts
// Grows for the lifetime of the process, one entry per distinct key, forever
const cache = new Map<string, Result>();

export function get(key: string) {
  if (!cache.has(key)) cache.set(key, compute(key));
  return cache.get(key)!;
}

It looks like a cache and behaves like a leak, because nothing ever removes anything. A cache has an eviction policy. A Map has none. Give it a bounded LRU with a TTL, or move it out of the process entirely, since Redis gives you eviction, shared state across instances and a size you can actually look at. Worth knowing first that what you put in there has a size of its own, and compressing it can matter as much as bounding it.

Listeners added per request are the next most common.

ts
// The emitter is long-lived; the handler closes over req and never leaves
bus.on("tick", () => doSomethingWith(req.user));

Node warns once a single emitter passes ten listeners. Treat MaxListenersExceededWarning as a leak report rather than a configuration nag, because it almost always is one. Use once, or remove the handler when the request ends.

Timers are the quiet one. A setInterval whose callback closes over a large object keeps that object alive as long as the timer exists, which is forever unless someone calls clearInterval. Timers created per request and never cleared retain the entire request, and they do it invisibly.

And streams or connections not destroyed on error paths. The happy path cleans up properly; the catch returns early and leaves a socket or a file handle open. These show up as growth in external rather than heapUsed, which is why the first check in this post matters.

Seatbelts are not fixes

While you are investigating it is reasonable to limit the damage, as long as nobody on the team mistakes any of this for the repair.

--max-old-space-size=<MB> sets the heap ceiling, and you want it below the container limit so V8 fails before the kernel kills the process. A readable crash beats a silent one. A process manager that restarts on memory thresholds does the same job more bluntly. And alert on the baseline rather than the peak, because the baseline climbing is the leak and the peak just moves with traffic.

All three buy time. None of them removes the collection that is still growing.

It is nearly always one line

A Node memory leak is almost always a collection that only grows, held by something that outlives the request that filled it.

The method does not vary. Confirm the baseline never returns. Take two snapshots with real traffic between them. Compare, sort by delta, read the retainer chain down to whatever is holding the reference.

Raising the memory limit changes how often you get paged about it. That is the entire effect.

Frequently asked

How do I know if my Node.js app has a memory leak?

Watch heap usage across a full traffic cycle. Memory that rises under load and falls back afterwards is normal; memory that rises under load and never returns to its earlier baseline, cycle after cycle, is a leak. A single rising graph proves nothing, because a healthy process grows until garbage collection decides it needs to run.

What usually causes a memory leak in Node.js?

A collection that only ever grows. The usual suspects are a module-level Map or array used as a cache with no eviction, event listeners added per request on a long-lived emitter, timers holding closures over request data, and streams or connections that are never destroyed on error paths.

How do I take a heap snapshot in production?

Call v8.writeHeapSnapshot() from inside the process, triggered by a signal or an admin route you control. It writes a file you can open in Chrome DevTools. Be aware it pauses the process for the duration and the file can be as large as the heap, so take it on one instance out of rotation rather than across the fleet.

Does increasing --max-old-space-size fix a memory leak?

No. It raises the ceiling the process crashes at, which makes the crash less frequent and the eventual garbage collection pauses longer. It is useful as a stopgap while you investigate, and as a permanent answer it only means you will be paged about it later, on a worse day.

Found this useful?Share on XShare on LinkedIn

Related reading