MervCodes

Tech Reviews From A Programmer

How to Find and Fix Memory Leaks in Node.js Applications

8 min read

I've been woken up at 3am by OOM-killed Node.js processes more times than I care to count. Memory leaks in Node.js are subtle and devastating — your application starts fine, handles traffic for hours, and then the process just dies. No graceful shutdown, no warning, just gone. Here's everything I've learned about finding, understanding, and actually fixing these leaks.

TL;DR: Learn to detect, diagnose, and fix memory leaks in Node.js with heap snapshots, profiling tools, and common patterns that cause leaks.

How Node.js Memory Works

Node.js uses V8's garbage collector to manage memory. Objects are allocated on the heap, and the GC reclaims memory when objects are no longer referenced. A memory leak occurs when objects that should be garbage collected remain referenced, causing the heap to grow indefinitely.

Checking Memory Usage

Start by monitoring your application's memory:

// Add this to your server startup
setInterval(() => {
  const usage = process.memoryUsage();
  console.log({
    rss: `${Math.round(usage.rss / 1024 / 1024)}MB`,
    heapUsed: `${Math.round(usage.heapUsed / 1024 / 1024)}MB`,
    heapTotal: `${Math.round(usage.heapTotal / 1024 / 1024)}MB`,
    external: `${Math.round(usage.external / 1024 / 1024)}MB`,
  });
}, 10_000);

If heapUsed grows continuously without plateauing, you have a leak.

Common Cause 1: Closures Holding References

The most common Node.js memory leak. A closure captures a variable that prevents an entire object tree from being garbage collected:

// LEAK: the closure holds a reference to `largeData`
function processRequest(req: Request) {
  const largeData = loadEntireDataset(); // 50MB array

  return {
    getSummary: () => {
      // This closure only needs one field, but it captures ALL of largeData
      return largeData.length;
    },
  };
}

Fix: Extract Only What You Need

function processRequest(req: Request) {
  const largeData = loadEntireDataset();
  const count = largeData.length; // Extract the value

  return {
    getSummary: () => {
      return count; // Closure only captures the primitive
    },
  };
}

Common Cause 2: Event Listener Accumulation

Adding event listeners without removing them is a classic leak:

// LEAK: new listener added on every request
app.get('/stream', (req, res) => {
  const handler = (data: Buffer) => {
    res.write(data);
  };

  dataStream.on('data', handler);

  // If the client disconnects, the handler is never removed
  // It accumulates with every request
});

Fix: Clean Up on Disconnect

app.get('/stream', (req, res) => {
  const handler = (data: Buffer) => {
    res.write(data);
  };

  dataStream.on('data', handler);

  req.on('close', () => {
    dataStream.off('data', handler); // Remove the listener
  });
});

Node.js warns you when an emitter has more than 10 listeners. Do not increase maxListeners to suppress the warning — it exists to catch leaks.

Common Cause 3: Unbounded Caches

In-memory caches without eviction policies will grow forever:

// LEAK: cache grows without limit
const cache = new Map<string, object>();

function getCachedUser(id: string) {
  if (cache.has(id)) {
    return cache.get(id);
  }
  const user = fetchUser(id);
  cache.set(id, user); // Never evicted
  return user;
}

Fix: Use an LRU Cache or WeakMap

import { LRUCache } from 'lru-cache';

const cache = new LRUCache<string, object>({
  max: 500,           // Maximum 500 entries
  ttl: 1000 * 60 * 5, // Expire after 5 minutes
});

function getCachedUser(id: string) {
  const cached = cache.get(id);
  if (cached) return cached;

  const user = fetchUser(id);
  cache.set(id, user);
  return user;
}

For caches keyed by object references, use WeakMap which allows garbage collection of keys:

const metadata = new WeakMap<object, Metadata>();
// When the key object is GC'd, the entry is automatically removed

Common Cause 4: Uncleared Timers

setInterval and setTimeout with closures prevent garbage collection:

// LEAK: interval runs forever, holding references
function startPolling(connection: Connection) {
  setInterval(async () => {
    const data = await connection.query('SELECT 1');
    // `connection` is never released
  }, 5000);
}

Fix: Store and Clear Timer References

function startPolling(connection: Connection) {
  const timer = setInterval(async () => {
    const data = await connection.query('SELECT 1');
  }, 5000);

  // Return cleanup function
  return () => {
    clearInterval(timer);
    connection.release();
  };
}

// Usage
const stopPolling = startPolling(connection);
// Later, when done:
stopPolling();

Common Cause 5: Global Variables and Module-Level State

Variables at module scope persist for the entire process lifetime:

// LEAK: grows with every imported module that calls addPlugin
const plugins: Plugin[] = [];

export function addPlugin(plugin: Plugin) {
  plugins.push(plugin);
}

// Also a leak: storing request data at module scope
let lastRequest: Request; // Holds reference to the last request forever

Fix: Use Scoped State or Weak References

// Use a class with explicit lifecycle
class PluginManager {
  private plugins: Plugin[] = [];

  add(plugin: Plugin) {
    this.plugins.push(plugin);
  }

  destroy() {
    this.plugins.length = 0;
  }
}

Detecting Leaks: Heap Snapshots

The most powerful diagnostic tool. Take heap snapshots at different times and compare them.

Using Chrome DevTools

Start your Node.js app with the inspect flag:

node --inspect dist/server.js
  1. Open chrome://inspect in Chrome
  2. Click "inspect" on your Node.js process
  3. Go to the Memory tab
  4. Take a heap snapshot (baseline)
  5. Run your suspected leaky operation multiple times
  6. Take another heap snapshot
  7. Select "Comparison" view between the two snapshots
  8. Sort by "Delta" to find objects that grew

Using the V8 Heap Profiler Programmatically

import v8 from 'node:v8';
import fs from 'node:fs';

// Take a heap snapshot on demand
function takeHeapSnapshot() {
  const filename = `/tmp/heap-${Date.now()}.heapsnapshot`;
  const snapshotStream = v8.writeHeapSnapshot(filename);
  console.log(`Heap snapshot written to ${filename}`);
  return filename;
}

// Expose via API endpoint (development only!)
app.get('/debug/heap-snapshot', (req, res) => {
  if (process.env.NODE_ENV !== 'development') {
    return res.status(403).send('Not available in production');
  }
  const filename = takeHeapSnapshot();
  res.json({ filename });
});

Detecting Leaks: Memory Timeline

For a higher-level view, use the allocation timeline:

node --inspect dist/server.js

In Chrome DevTools Memory tab, select "Allocation instrumentation on timeline" and record while reproducing the issue. Blue bars that never disappear indicate retained allocations.

Automated Leak Detection

Add automated monitoring to catch leaks before they crash production:

const HEAP_LIMIT = 512 * 1024 * 1024; // 512MB

setInterval(() => {
  const { heapUsed } = process.memoryUsage();

  if (heapUsed > HEAP_LIMIT) {
    console.error(`Memory limit exceeded: ${Math.round(heapUsed / 1024 / 1024)}MB`);

    // Take a diagnostic snapshot
    v8.writeHeapSnapshot(`/tmp/oom-${Date.now()}.heapsnapshot`);

    // Graceful shutdown
    process.exit(1); // Let your process manager restart it
  }
}, 30_000);

In production, use a proper APM tool like Datadog, New Relic, or the open-source Clinic.js:

npx clinic doctor -- node dist/server.js

Clinic.js generates visual reports showing memory growth patterns and helps identify the cause.

Prevention Checklist

  1. Always remove event listeners when they are no longer needed. Use AbortController for request-scoped listeners.
  2. Cap all caches with maximum size and TTL. Never use a plain Map as a cache in production.
  3. Clear timers when the associated resource is destroyed.
  4. Avoid module-level mutable state unless you have a clear lifecycle for cleanup.
  5. Use WeakMap and WeakRef when you want to cache objects without preventing their garbage collection.
  6. Monitor heap usage in production with alerts at 70% and 90% of your memory limit.

Key Takeaways

  • Memory leaks are caused by objects that should be freed but remain referenced
  • The top causes are closures, event listeners, unbounded caches, and timers
  • Heap snapshots with Chrome DevTools are the definitive diagnostic tool
  • Prevention is easier than detection: cap caches, clean up listeners, clear timers
  • Monitor heap usage in production and set up alerts before you hit OOM

Sources

  1. Node.js Documentation
  2. Chrome DevTools — Memory
  3. MDN Web Docs

Looking for more? Check out Adaptels.

Related Articles

How to Debug Node.js Memory Leaks (Step-by-Step Guide)

Learn how to detect, diagnose, and fix Node.js memory leaks using heap snapshots, Chrome DevTools, and clinic.js — with real code examples.

How to Deploy a Node.js App to AWS EC2 (Step-by-Step Guide)

Deploy Node.js apps to AWS EC2 with this production-ready guide. Learn instance setup, PM2, Nginx, SSL, and automated deployments.

How to Fix CORS Errors in Node.js and Express (Complete Guide)

Master CORS errors in Express. Learn what causes them, how to fix them, and best practices for production APIs with practical examples.