Memory Leaks

advanced nodejs memory performance leaks

A memory leak in Node is when our process keeps holding on to memory it doesn’t need anymore. RSS climbs. Eventually we hit the heap limit (default ~1.5 GB on 64-bit) and V8 kills us with JavaScript heap out of memory.

JavaScript has a garbage collector — it frees objects nothing references. So a “leak” really means we’re still referencing the object even though we don’t need it. Find the reference, break it, leak fixed.

The classic causes

1. Closures over big data

function buildHandler(hugeDataset) {
  return function (req, res) {
    res.json({ count: hugeDataset.length });
  };
}

app.get('/count', buildHandler(loadGigabyteFile()));

The handler captures hugeDataset. As long as the handler is registered (forever), the dataset stays in memory. Even if we only ever read .length from it.

Fix: don’t close over data we don’t need.

const count = loadGigabyteFile().length; // extract what we need
app.get('/count', (req, res) => res.json({ count }));
// hugeDataset can be GC'd now

2. EventEmitter listener leaks

Every .on() adds a listener. If we add listeners in a request handler without removing them, they pile up forever.

// BAD — adds a listener per request
app.get('/stream', (req, res) => {
  someEmitter.on('data', (chunk) => res.write(chunk));
});

Node warns us once we cross 10 listeners on the same event:

(node:1234) MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
11 data listeners added to [EventEmitter].

Fixes:

  • Use .once() if we only need it once.
  • Remove the listener when we’re done: emitter.off('data', fn).
  • For per-request listeners, attach to a per-request object (the response stream), not a shared global emitter.

3. Unbounded global caches

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

Looks innocent. After a million unique user IDs, our Map has a million entries. Forever.

Fix: bounded cache with TTL/LRU.

import { LRUCache } from 'lru-cache';
const cache = new LRUCache({ max: 10_000, ttl: 1000 * 60 * 5 });

4. Timers that capture context

function handleConnection(conn) {
  setInterval(() => conn.ping(), 30_000);
}

If conn disconnects but we never clearInterval, the timer keeps conn alive. Always store the timer ID and clear it on cleanup.

5. Global arrays we push to and never drain

const recentRequests = [];
app.use((req, res, next) => {
  recentRequests.push({ url: req.url, time: Date.now() });
  next();
});

Grows forever. Use a ring buffer, or push to a real log system.

Spotting a leak

The telltale sign: RSS climbs steadily under steady load and never comes back down. A healthy process has memory that goes up during traffic, then GC reclaims it during quiet periods, oscillating in a band. A leaking process trends up monotonically.

// Cheap monitoring
setInterval(() => {
  const m = process.memoryUsage();
  console.log({
    rss: (m.rss / 1024 / 1024).toFixed(1) + 'MB',
    heapUsed: (m.heapUsed / 1024 / 1024).toFixed(1) + 'MB',
  });
}, 10_000);
Leak Detection Workflow
1. Reproduce — script that drives the suspect path in a loop
2. Take heap snapshot (baseline, after warmup)
3. Run loop for N minutes
4. Take second snapshot
5. DevTools → Comparison view → sort by Delta
6. Open the top growing class → "Retainers" → follow chain
7. Fix the reference. Re-test.

Force GC for cleaner snapshots

V8 might be holding objects that are technically collectable. Force a GC right before snapshotting:

node --expose-gc server.js
if (global.gc) global.gc();
// now take snapshot

Otherwise we end up chasing “leaks” that are really just GC laziness.

When it’s not actually a leak

  • First few minutes of high traffic — V8’s heap grows up to its working set. Normal.
  • heapTotal grows but heapUsed stays flat — heap fragmentation, not a leak.
  • Native memory growth — RSS grows but heap doesn’t. Could be a native addon (sharp, bcrypt, gRPC) leaking C++ memory. Way harder to debug.

The boring fix nobody talks about

If we can’t find the leak in a hurry and the process is going to OOM in 12 hours, restart it on a schedule. PM2’s max_memory_restart or Kubernetes’ liveness probe + memory limit will recycle the process before it dies. Not glamorous but buys us time to actually fix it.