Hunting Node.js Memory Leaks in 2026: Heap Snapshots and Clinic.js That Actually Work

Your Node.js service has been running for six hours and RSS is now at 2.1 GB. Restart it, watch it creep back up. You add a --max-old-space-size flag and call it a day. The process dies at 3 AM instead of 6 AM. Congratulations, you’ve just made the problem slightly harder to notice.

Memory leaks in Node.js are stubborn not because V8 is broken, but because JavaScript makes it trivially easy to accidentally keep references alive forever — and the default tooling gives you almost zero signal until things are already on fire.

This guide walks you through the full diagnostic workflow: from spotting the leak with Clinic.js to pinpointing the exact allocation with heap snapshots. No hand-waving, no "just use WeakMap" generic advice — actual tooling, actual output interpretation, and the pitfalls you’ll hit if you skip steps.


Why Node.js Leaks Are Different From What You Expect

The GC is not the problem. V8’s garbage collector is genuinely good. The problem is that you (or a library) are holding references to objects that should be dead. The GC can’t collect what’s still reachable, and in JavaScript, "still reachable" can mean a lot of accidental things:

  • An event emitter that was never .removeListener()-ed
  • A closure capturing a large buffer in a callback chain that never resolves
  • A module-level cache that grows without bounds
  • A setInterval keeping a reference to a stale object graph alive
  • A third-party library using process.on('exit', ...) that accumulates per-request state

The V8 heap is divided into spaces. The one you care about is old space — objects that survived multiple GC cycles live here. When old space grows monotonically and never shrinks, you have a leak. Everything else is normal GC churn.


The Toolchain You Need

  • Clinic.js — a suite of profilers from NearForm. Three tools in one: doctor for high-level signal, heapprofiler for allocation traces, flame for CPU. Install it once, use it everywhere.
  • V8’s built-in heap snapshot API — v8.writeHeapSnapshot() ships with Node since v11. No external deps, works in production.
  • Chrome DevTools — the best heap snapshot viewer that exists. Open-source, already on your machine.

Optional but useful: node --inspect + ndb for interactive profiling sessions, and autocannon or wrk for generating load while you profile.

npm install -g clinic
npm install -g autocannon

That’s the whole setup.


Step 1: Confirm You Actually Have a Leak With Clinic Doctor

Before you spend three hours reading heap snapshots, confirm the leak is real. Clinic.js doctor instruments your process and gives you a high-level view of memory, CPU, event loop lag, and handles over time.

# Run your app through clinic doctor
clinic doctor -- node server.js

# In another terminal, send load to it
autocannon -c 100 -d 30 https://cd-linux.club:3000/

When the load finishes, Clinic kills the process and opens an HTML report in your browser. You’re looking at four graphs. Focus on the Heap Used graph:

  • A sawtooth pattern (grows, drops, grows, drops) → normal GC behavior, no leak
  • A monotonically climbing line that never drops back down → leak confirmed
  • A mostly flat line with occasional drops → you’re fine, probably just high allocation rate

If heap used climbs continuously under load and doesn’t recover during idle periods, move on to step 2.

Gotcha: Clinic doctor adds around 15–20% CPU overhead. Do not run it on a production node under real traffic if you’re already CPU-bound. Use a staging replica or a dedicated load test.


Step 2: Find What’s Allocating With Clinic Heapprofiler

clinic heapprofiler samples heap allocations and builds a flame graph of where memory is being allocated. It answers "what code path is allocating the most?" not "what is alive in the heap right now." Subtle but important distinction.

clinic heapprofiler -- node server.js

Send load again while it’s running. When you stop it, you get a flame graph where bar width represents bytes allocated. Look for:

  • Wide bars deep in your application code (not in node_modules)
  • Allocations inside request handlers that should be short-lived but appear at the top of large stacks
  • Buffers being created inside loops without being released

This is your shortlist of suspects. Now you need a heap snapshot to see what’s actually surviving GC.


Step 3: Take Heap Snapshots — The Right Way

A heap snapshot is a point-in-time dump of everything alive in the V8 heap. The classic workflow is to take three: one at baseline, one mid-run, one after a long run. Compare them to see what grew.

Programmatic snapshots via v8.writeHeapSnapshot()

Add this to your app. Behind a signal or a private HTTP endpoint — never exposed publicly.

const v8 = require('v8');
const path = require('path');

// Triggered via: kill -SIGUSR2 <pid>
process.on('SIGUSR2', () => {
  const filename = v8.writeHeapSnapshot(
    path.join('/tmp', `heap-${Date.now()}.heapsnapshot`)
  );
  console.error(`[heap] snapshot written to ${filename}`);
});

Now take snapshots at three stages:

# Snapshot 1: before any load (baseline)
kill -SIGUSR2 $(pgrep -f "node server.js")

# Wait for the process to warm up and handle requests
autocannon -c 50 -d 60 https://cd-linux.club:3000/

# Snapshot 2: immediately after load
kill -SIGUSR2 $(pgrep -f "node server.js")

# Wait 60 more seconds (let GC run)
sleep 60

# Snapshot 3: after GC has had a chance to clean up
kill -SIGUSR2 $(pgrep -f "node server.js")

Any object that appears in snapshot 3 and not snapshot 1 — and grew between snapshot 2 and 3 — is a leak candidate.

Production-ready tip: Use /tmp or a ramdisk for snapshots. A heap snapshot of a 500 MB process can produce a 1–2 GB .heapsnapshot file. Don’t write it to a disk partition that also holds your logs.

Via --inspect and Chrome DevTools (interactive)

For local debugging, this is faster:

node --inspect server.js

Open Chrome, navigate to chrome://inspect, click your Node target, go to the Memory tab. Click Take Heap Snapshot. Do your thing. Take another one. Use the Comparison view.


Step 4: Reading the Snapshot in Chrome DevTools

Open your .heapsnapshot file: Chrome DevTools → Memory tab → Load button.

Switch to Comparison mode (compare snapshot 2 vs snapshot 1, then snapshot 3 vs snapshot 1). Sort by # Delta (objects added) or Size Delta (bytes added).

The columns:

  • Constructor — the class or type of the object
  • # Delta — how many more of these exist compared to the baseline
  • Alloc. Size Delta — how many more bytes
  • Retained Size — how much memory would be freed if this object was collected (including everything it holds a reference to)

You’re looking for objects with a high Retained Size Delta that belong to your application code, not to Node internals.

Gotcha: Don’t panic about (string), (array), or (system) entries. Those are expected. Focus on entries that match your domain objects — class names, constructor names that appear in your source.

Clicking on a suspicious entry expands it. The Retainers panel at the bottom shows what’s holding a reference to this object. Follow the chain upward until you find the root — the thing that’s preventing GC.


Common Leak Patterns and How They Show Up

Unremoved event listeners

// Leaks every time makeRequest() is called
function makeRequest(emitter) {
  emitter.on('data', (chunk) => {
    // chunk captured in closure, emitter holds reference forever
  });
}

In a heap snapshot this shows up as a growing count of (closure) objects retained by an EventEmitter. Fix: always call .removeListener() or use .once() when you only need the event once.

Module-level caches without eviction

const cache = new Map(); // module-level, lives forever

app.get('/user/:id', async (req, res) => {
  if (!cache.has(req.params.id)) {
    cache.set(req.params.id, await fetchUser(req.params.id));
  }
  res.json(cache.get(req.params.id));
});

After enough unique requests, that Map contains every user ever fetched. The fix is either an LRU cache with a max size, or WeakRef + FinalizationRegistry for genuinely optional caching.

Promises that never settle

A Promise that never resolves or rejects holds everything in its closure alive indefinitely. These are nasty because they’re invisible — no error, no warning. In a heap snapshot they appear as (closure) chains with deep async stacks.

Always add timeouts to external calls:

const fetchWithTimeout = (url, ms = 5000) => {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);
  return fetch(url, { signal: controller.signal })
    .finally(() => clearTimeout(timer));
};

Stale timers

class Poller {
  start() {
    this.timer = setInterval(() => this.poll(), 1000);
  }
  // No stop() method. Timer keeps Poller instance alive forever.
}

If you create objects with timers and discard them without calling clearInterval, the timer’s callback closure keeps the object alive. Always pair setInterval with clearInterval and expose a cleanup method.


Gotchas

Snapshot timing matters. If you take a snapshot immediately after a burst of requests, you’ll see a lot of GC-eligible objects that haven’t been collected yet. Always wait for at least two GC cycles (a few seconds of idle) before taking your "after" snapshot. Otherwise you’ll chase ghosts.

Node’s --expose-gc flag. During debugging, you can manually trigger GC with global.gc() if you start Node with --expose-gc. Useful for forcing a clean state before a snapshot. Never ship this in production — it pauses the event loop.

Snapshots are synchronous. v8.writeHeapSnapshot() stops the world for the duration. On a large heap this can take several seconds. Under production load, this will cause request timeouts. Schedule snapshots during low-traffic windows or use a worker process.

Clinic.js and ESM. If your project uses ES modules ("type": "module" in package.json), Clinic.js may require the --expose-internals flag or might not work at all depending on the Node version. Fall back to --inspect + Chrome DevTools for ESM projects and watch the Clinic.js ESM issue tracker for progress.

RSS ≠ heap size. Resident Set Size includes your heap, but also V8’s code space, C++ objects, Buffers (which live outside the V8 heap in native memory), and shared memory. A growing RSS with a stable heap means you have a native/Buffer leak, not a JS heap leak. Check Buffer allocations separately.


Production-Ready Workflow

Here’s the workflow I use on any Node.js service before shipping:

1. Add the SIGUSR2 snapshot endpoint at startup, always. It costs nothing when idle and is invaluable when you need it at 2 AM.

2. Track process.memoryUsage().heapUsed in your metrics. Expose it to Prometheus or whatever your stack uses. Alert when it grows more than 20% over a 30-minute rolling window without a corresponding traffic spike.

3. Periodic GC logging in staging. Run with node --trace-gc --trace-gc-ignore-scavenger server.js for an hour under load. If major GC (Mark-Compact) is running more frequently than every few minutes, something is generating too many long-lived objects.

4. Test for leaks explicitly. Write a test that runs your handler N times and checks that heap size after N iterations is not proportional to N:

// crude but effective smoke test
const v8 = require('v8');

async function detectLeak(handler, iterations = 1000) {
  // warm up
  for (let i = 0; i < 100; i++) await handler();
  
  const before = v8.getHeapStatistics().used_heap_size;
  for (let i = 0; i < iterations; i++) await handler();
  
  // force GC if available
  if (global.gc) global.gc();
  
  const after = v8.getHeapStatistics().used_heap_size;
  const growthPerRequest = (after - before) / iterations;
  
  if (growthPerRequest > 1024) { // more than 1KB per request retained
    throw new Error(`Possible leak: ${growthPerRequest.toFixed(0)} bytes/request retained`);
  }
}

Run this in CI with --expose-gc. It won’t catch every leak but it will catch the obvious ones before they reach production.

5. Keep heap snapshots off your master branch. .heapsnapshot files are binary, huge, and useless to diff. Add *.heapsnapshot to .gitignore right now.


The Real Takeaway

Memory leaks in Node.js aren’t usually mysterious. They’re almost always the same handful of patterns: something is accumulating references that should have been released. The reason they feel mysterious is that the default observability is terrible — you get an RSS number and nothing else until the process crashes.

Adding the snapshot signal handler takes five minutes. Adding heap metrics to your dashboard takes ten. After that, when something leaks, you have a path from "RSS is climbing" to "line 47 of routes/users.js is the culprit" instead of hours of guessing.

Clinic.js gives you the macro view. Heap snapshots give you the micro view. Use both. Take baseline snapshots of healthy processes and save them. When something leaks in production, the diff between a healthy snapshot and a sick one will tell you almost everything.

👁 Views: 202,779 · Unique visitors: 57,387