Wise Hustlers — Digital Product & App Development Studio Logo
Get Consultation
By Wise Hustler Admin8/20/20268 min read

Node.js Memory Leaks: How to Find Them Before They Find Production

Node.js Memory Leaks: How to Find Them Before They Find Production

# Node.js Memory Leaks: How to Find Them Before They Find Production

TL;DR: Start the process with node --inspect, drive the suspect code path under load, capture two or more heap snapshots in Chrome DevTools, switch to Comparison view, and sort by retained size — the objects with a large positive delta between snapshots are your leak. The two most common root causes are unbounded in-memory caches and event listeners that never get removed.

Memory leaks in Node.js don't crash the process the way a null pointer dereference does in a language with manual memory management. They show up as a slow, deniable slope: RSS climbs a few megabytes an hour, the pod restarts itself under Kubernetes' memory limit every few days, and nobody notices until the restart cadence gets short enough to page someone. By the time it's an incident, the reproduction window is gone. The goal of this guide is to give you a repeatable way to catch the leak on a dev machine, with the actual commands, before it becomes a 3 a.m. problem.

Why Node.js leaks even though it's garbage collected

V8's garbage collector reclaims memory that is unreachable — nothing on the heap references it anymore. A leak in this context isn't a bug in the GC, it's your own code holding a live reference to something it should have let go of. The classic offenders:

  • Unbounded caches — a plain Map or object used as an in-memory cache with no eviction policy and no size cap.
  • Forgotten event listenersemitter.on(...) called repeatedly (e.g., once per request or per connection) without a matching removeListener/off.
  • Closures over large objects — a callback captured in a long-lived structure (a queue, a timer, a cache) that closes over a big buffer or request object it doesn't need.
  • Global collections — arrays or maps at module scope that only ever grow (log buffers, "recent activity" lists with no cap).

All four share a shape: something external to a single request's lifetime keeps a reference alive past the point the request finished.

Reproducing the leak: two realistic patterns

Pattern 1: the unbounded cache

// cache.js — leaks: no eviction, no size limit
const cache = new Map();

function getUserProfile(userId, fetchFromDb) {
  if (cache.has(userId)) return cache.get(userId);

  const profile = fetchFromDb(userId); // pretend DB call
  cache.set(userId, profile);
  return profile;
}

module.exports = { getUserProfile, cache };

Every unique userId that ever hits this function stays in cache forever. On a service with a large or unbounded ID space (public API, per-session data, per-request UUIDs used as keys by mistake), this grows without bound.

Pattern 2: the forgotten listener

// jobRunner.js — leaks: listener added per job, never removed
const { EventEmitter } = require('node:events');
const bus = new EventEmitter();

function runJob(job) {
  // BUG: a new listener is registered on every call, closing over `job`
  bus.on('job:complete', () => {
    console.log(`Job ${job.id} finished, cleaning up`, job.payload);
  });

  process.nextTick(() => bus.emit('job:complete'));
}

module.exports = { runJob, bus };

Each runJob call adds a new closure to bus, and each closure keeps job (including job.payload) alive for the life of the process. After ~10 listeners on one event, Node's EventEmitter will actually warn you about this on stderr — defaultMaxListeners is 10 by default, and exceeding it emits a MaxListenersExceededWarning with a stack trace pointing at the guilty .on() call (Node.js Events docs). That warning is a legitimate early-detection signal, not just noise — don't silence it with setMaxListeners() without first checking whether it's telling you about a real leak.

Step 1: start the process with `--inspect`

node --inspect server.js
# or, to pause at the first line and attach before anything runs:
node --inspect-brk server.js

This opens a WebSocket-based debugger endpoint (default 127.0.0.1:9229) that speaks the Chrome DevTools Protocol. In Chrome or Edge, go to chrome://inspect, click Configure to make sure localhost:9229 is listed as a target, then click inspect under "Remote Target" once your process shows up — or click "Open dedicated DevTools for Node," which attaches directly without the intermediate page (Chrome DevTools memory docs).

Never bind the inspector to 0.0.0.0 or expose port 9229 outside a trusted network — the inspector protocol gives full code-execution access to the process, so --inspect=0.0.0.0 on a public-facing box is a remote code execution hole, not a debugging convenience.

Step 2: capture and diff heap snapshots

With DevTools attached, go to the Memory tab:

1. Select Heap snapshot, click Take snapshot. This is your baseline.

2. Exercise the suspect path — for the cache example, hit the endpoint with a batch of unique user IDs; for the listener example, run several jobs.

3. Force a GC pass first (DevTools has a trash-can "Collect garbage" icon in the Memory tab) so you're not comparing against objects that were about to be collected anyway, then take a second snapshot.

4. In the snapshot list, select the second snapshot and switch the view dropdown from Summary to Comparison, with the first snapshot as the baseline.

5. Sort by # Delta or Size Delta, descending.

For the cache example, you'll see Map retaining a growing number of entries with # New far outweighing # Deleted between snapshots. For the listener example, look under the EventEmitter or Object constructor for a mounting number of Function (closure) instances — expand one and check its retainers; DevTools shows the reference chain (e.g., bus._events['job:complete'] → Array → Function), which points you straight back to the .on() call that never got cleaned up (Node.js Learn: Using Heap Snapshot; Chrome DevTools: Record heap snapshots).

You can also generate snapshots programmatically, without the inspector UI, which is more practical for capturing state from a running production instance:

const { writeHeapSnapshot } = require('node:v8');

// writes a .heapsnapshot file you can later drag into DevTools' Memory panel
process.on('SIGUSR2', () => {
  const file = writeHeapSnapshot();
  console.log(`Heap snapshot written to ${file}`);
});
kill -USR2 <pid>

Trigger it twice under load, a few minutes apart, scp both .heapsnapshot files off the box, and load them into DevTools' Memory tab locally for the same comparison workflow — no live inspector connection to a production host required.

Step 3: fix the two patterns

Cache fix — cap size and add eviction (an LRU is the standard choice; the lru-cache npm package is the common pick, or roll a minimal Map-based one with a max-size check on insert):

const { LRUCache } = require('lru-cache');
const cache = new LRUCache({ max: 5000, ttl: 1000 * 60 * 30 });

function getUserProfile(userId, fetchFromDb) {
  if (cache.has(userId)) return cache.get(userId);
  const profile = fetchFromDb(userId);
  cache.set(userId, profile);
  return profile;
}

Listener fix — attach once outside the per-call path, or explicitly remove:

function runJob(job) {
  const onComplete = () => {
    console.log(`Job ${job.id} finished`, job.payload);
    bus.off('job:complete', onComplete); // clean up after firing
  };
  bus.once('job:complete', onComplete); // or bus.once() if it only fires once
  process.nextTick(() => bus.emit('job:complete'));
}

emitter.once() auto-removes itself after firing, which is usually the right call for one-shot completion events like this.

Quick reference

SymptomLikely causeWhere to look in DevTools
RSS grows steadily under constant trafficUnbounded cache/collectionComparison view, sort by # Delta on Map/Object
MaxListenersExceededWarning in logsListener added repeatedly, never removedEventEmitter retainers, _events array
Memory grows only on a specific routeClosure capturing large request/response dataRetainer path from the growing constructor back to the route handler
Slow growth, no obvious patternTimers/intervals never cleared (setInterval without clearInterval)Look for growing Timeout object counts

If your team is running Node.js services at scale and doesn't yet have memory/CPU alerting or a repeatable profiling workflow wired into CI or staging, that's the kind of gap a cloud/DevOps engagement is built to close — but the debugging technique above works the same whether you do it yourself or bring in help.

FAQ

Does `--inspect` slow down or change behavior in production?

The inspector itself has low overhead when nothing is attached, but leaving port 9229 reachable is a security risk, not a performance one. For production, prefer the node:v8 writeHeapSnapshot() + SIGUSR2 approach above so you capture a snapshot without opening a debugger port at all.

How many heap snapshots do I need to confirm a leak?

Two is the minimum for a diff; three is better. If an object's count grows between snapshot 1→2 and again between 2→3, that's a real trend, not a one-off GC timing artifact.

Why does memory usage grow even without a leak?

V8 doesn't collect garbage instantly — RSS naturally rises between GC cycles and can plateau higher under load as V8 sizes its heap to reduce GC frequency. Force a GC (DevTools trash-can icon, or run Node with --expose-gc and call global.gc()) before snapshotting to rule this out.

Is `clinic.js` still worth using in 2026?

Clinic.js (from NearForm) is a well-known suite for CPU and heap profiling, but it's no longer actively maintained and can have compatibility issues on current Node versions, so treat results as a starting hint rather than ground truth, and confirm findings with DevTools' native heap snapshot comparison (Clinic.js on GitHub).

Sources