DEV Community

Mack
Mack

Posted on

How to Debug Memory Leaks in Node.js (A Practical Guide)

Memory leaks in Node.js are sneaky. Your app works fine in dev, passes all tests, then slowly eats RAM in production until it crashes at 3 AM.

Here's how to find and fix them — no PhD required.

The Symptoms

Before debugging, confirm you actually have a leak:

# Watch memory usage over time
node --expose-gc -e 'setInterval(() => { global.gc(); console.log(process.memoryUsage().heapUsed / 1024 / 1024, "MB"); }, 1000);'
Enter fullscreen mode Exit fullscreen mode

If heapUsed keeps growing after forced GC, you have a leak.

The Usual Suspects

1. Event Listeners That Never Die

The #1 cause. Every .on() without a corresponding .off() is a potential leak.

// ❌ Leak: listener accumulates on every request
app.get('/stream', (req, res) => {
  database.on('change', (data) => {
    res.write(JSON.stringify(data));
  });
});

// ✅ Fixed: clean up when connection closes
app.get('/stream', (req, res) => {
  const handler = (data) => res.write(JSON.stringify(data));
  database.on('change', handler);
  req.on('close', () => database.off('change', handler));
});
Enter fullscreen mode Exit fullscreen mode

Node warns you at 11 listeners per emitter. Don't suppress this warning — fix the leak.

2. Closures Holding References

// ❌ Leak: closure keeps `hugeData` alive
function processRequest(hugeData) {
  return function respond() {
    // Doesn't use hugeData, but closure captures it anyway
    return { status: 'ok' };
  };
}

// ✅ Fixed: null out after use
function processRequest(hugeData) {
  const result = transform(hugeData);
  hugeData = null; // Allow GC
  return function respond() {
    return result;
  };
}
Enter fullscreen mode Exit fullscreen mode

3. Caches Without Limits

// ❌ Leak: grows forever
const cache = {};
function getUser(id) {
  if (!cache[id]) cache[id] = db.findUser(id);
  return cache[id];
}

// ✅ Fixed: use an LRU cache
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({ max: 500 });
function getUser(id) {
  if (!cache.has(id)) cache.set(id, db.findUser(id));
  return cache.get(id);
}
Enter fullscreen mode Exit fullscreen mode

4. Timers and Intervals

// ❌ Leak: interval keeps running after module is done
setInterval(() => {
  metrics.push(getStats()); // `metrics` array grows forever
}, 1000);

// ✅ Fixed: clear interval and bound the array
const id = setInterval(() => {
  metrics.push(getStats());
  if (metrics.length > 1000) metrics.shift();
}, 1000);

// Clear when done
process.on('SIGTERM', () => clearInterval(id));
Enter fullscreen mode Exit fullscreen mode

Finding the Leak

Step 1: Heap Snapshots

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

// Take a snapshot
const snapshotStream = v8.writeHeapSnapshot();
console.log('Snapshot written to', snapshotStream);
Enter fullscreen mode Exit fullscreen mode

Take two snapshots 5 minutes apart, then compare in Chrome DevTools:

  1. Open chrome://inspect
  2. Click "Open dedicated DevTools for Node"
  3. Go to Memory tab → Load both snapshots
  4. Switch to "Comparison" view
  5. Sort by "Size Delta" — the growing objects are your leak

Step 2: The Allocation Timeline

For harder cases, use --inspect with the allocation timeline:

node --inspect server.js
Enter fullscreen mode Exit fullscreen mode
  1. Open Chrome DevTools → Memory tab
  2. Select "Allocation instrumentation on timeline"
  3. Hit record, exercise your app, stop recording
  4. Blue bars that don't disappear = retained memory = leak candidates

Step 3: Production Profiling

Can't reproduce locally? Use process.memoryUsage() strategically:

// Add to your healthcheck endpoint
app.get('/health', (req, res) => {
  const mem = process.memoryUsage();
  res.json({
    heapUsed: Math.round(mem.heapUsed / 1024 / 1024) + ' MB',
    heapTotal: Math.round(mem.heapTotal / 1024 / 1024) + ' MB',
    rss: Math.round(mem.rss / 1024 / 1024) + ' MB',
    external: Math.round(mem.external / 1024 / 1024) + ' MB',
    uptime: Math.round(process.uptime()) + 's'
  });
});
Enter fullscreen mode Exit fullscreen mode

Graph this over time. If heapUsed trends up across restarts, you have a leak.

The Nuclear Option

If you can't find the leak and need a quick fix:

// Restart worker when memory exceeds threshold
if (process.memoryUsage().heapUsed > 500 * 1024 * 1024) {
  console.error('Memory limit reached, restarting...');
  process.exit(1); // Let your process manager restart you
}
Enter fullscreen mode Exit fullscreen mode

This isn't a fix — it's a bandaid. But it keeps your app running while you investigate.

TL;DR

  1. Event listeners are the #1 cause — always clean up
  2. Closures capture more than you think
  3. Unbounded caches are silent killers
  4. Heap snapshots + comparison view = fastest diagnosis
  5. Don't suppress the MaxListenersExceededWarning

Memory leaks aren't mysterious. They're just references that outlive their usefulness — like that gym membership you're still paying for.


Found this useful? I write practical dev guides weekly. Follow for more no-fluff tutorials.

Top comments (0)