Nodejs

Debugging Memory Leaks in Node.js

A practical guide to finding and fixing memory leaks in Node.js applications covering heap snapshots, profiling tools, common leak patterns, and production monitoring.

Debugging Memory Leaks in Node.js

A memory leak is when your application allocates memory that is never released back to the system. In Node.js, this means objects that stay referenced long after they are needed, preventing the V8 garbage collector from reclaiming them. The heap grows gradually — 100 MB, 200 MB, 500 MB — until the process runs out of memory and crashes.

Memory leaks are insidious because they work fine in development (short sessions, few requests) but fail in production (running for days, millions of requests). A leak that grows by 1 KB per request seems harmless until the server has handled a million requests and consumed an extra gigabyte.

This guide covers how to identify, locate, and fix memory leaks in Node.js applications using heap snapshots, profiling tools, and systematic debugging techniques.

How V8 Garbage Collection Works

V8 uses a generational garbage collector with two main spaces:

New Space (Young Generation): Small, frequently collected. New objects are allocated here. Objects that survive a few collection cycles are promoted to Old Space. Collection is fast (1-5ms).

Old Space (Old Generation): Large, collected less frequently. Contains long-lived objects. Collection is slower and can cause brief pauses (10-100ms for major collections).

An object is eligible for garbage collection when nothing references it — no variable, no array element, no object property, no closure, and no event listener holds a reference to it.

A memory leak occurs when references to objects persist after the objects are no longer needed. The garbage collector cannot collect them because something still points to them.

Detecting Memory Leaks

Monitoring Memory Usage

// memory-monitor.js
var INTERVAL = 30000; // Check every 30 seconds
var GROWTH_THRESHOLD = 10; // Alert if heap grows > 10 MB between checks
var lastHeapUsed = 0;

setInterval(function() {
  var usage = process.memoryUsage();
  var heapUsedMB = Math.round(usage.heapUsed / 1024 / 1024);
  var heapTotalMB = Math.round(usage.heapTotal / 1024 / 1024);
  var rssMB = Math.round(usage.rss / 1024 / 1024);

  var growth = heapUsedMB - lastHeapUsed;

  console.log(JSON.stringify({
    type: "memory",
    heapUsed: heapUsedMB + "MB",
    heapTotal: heapTotalMB + "MB",
    rss: rssMB + "MB",
    growth: growth + "MB"
  }));

  if (lastHeapUsed > 0 && growth > GROWTH_THRESHOLD) {
    console.warn("Memory growth warning: +" + growth + "MB in last " + (INTERVAL / 1000) + "s");
  }

  lastHeapUsed = heapUsedMB;
}, INTERVAL);

If heapUsed grows consistently over time (not just during traffic spikes), you have a memory leak.

Quick Leak Detection Test

// leak-test.js — simulate load and watch memory
var http = require("http");

var TARGET = "http://localhost:3000/api/articles";
var REQUESTS = 10000;
var CONCURRENCY = 10;
var completed = 0;

function logMemory(label) {
  var usage = process.memoryUsage();
  console.log(label + ": " + Math.round(usage.heapUsed / 1024 / 1024) + "MB heap");
}

// Run this IN the server process, not as a separate script
// Add a route to trigger the test:
app.get("/debug/leak-test", function(req, res) {
  global.gc && global.gc(); // Force GC if --expose-gc is set
  logMemory("Before test");

  // Simulate 1000 requests to a route
  var promises = [];
  for (var i = 0; i < 1000; i++) {
    promises.push(
      db.query("SELECT 1").catch(function() {})
    );
  }

  Promise.all(promises).then(function() {
    setTimeout(function() {
      global.gc && global.gc();
      logMemory("After test + GC");
      res.json({ status: "done" });
    }, 5000);
  });
});

Run the server with node --expose-gc server.js to enable manual garbage collection. If memory after GC is significantly higher than before the test, there is a leak.

Heap Snapshots

Heap snapshots are the primary tool for finding memory leaks. A snapshot captures every object in memory, its size, and what references it.

Taking Heap Snapshots

Via Chrome DevTools

# Start the server with inspector
node --inspect server.js
  1. Open Chrome and navigate to chrome://inspect
  2. Click "inspect" on your Node.js process
  3. Go to the Memory tab
  4. Select Heap snapshot and click Take snapshot

Programmatically

var v8 = require("v8");

// Save a heap snapshot to disk
function takeSnapshot(label) {
  var filename = v8.writeHeapSnapshot();
  console.log("Heap snapshot saved:", filename, "(" + label + ")");
  return filename;
}

// Admin endpoint
app.post("/debug/heap-snapshot", authorize("admin"), function(req, res) {
  var filename = takeSnapshot(req.body.label || "manual");
  res.json({ file: filename });
});

Load the saved .heapsnapshot file in Chrome DevTools (Memory tab > Load).

Three-Snapshot Technique

The most reliable method for finding leaks:

  1. Snapshot 1: Baseline — take after the application has warmed up
  2. Generate load: Send many requests to the suspected leaky endpoint
  3. Force GC: global.gc() (requires --expose-gc)
  4. Snapshot 2: Take after load
  5. Generate more load: Send the same number of requests
  6. Force GC again
  7. Snapshot 3: Take after second load

Compare snapshots 2 and 3. Objects that grew between them are likely the leak. The first snapshot establishes a baseline. Growth between snapshots 2 and 3 (after the system has warmed up) indicates ongoing accumulation.

Analyzing Heap Snapshots

In Chrome DevTools with a snapshot loaded:

Summary view: Shows objects grouped by constructor name. Sort by "Retained Size" to find the largest groups. Look for unexpectedly large counts.

Comparison view: Select two snapshots and view the diff. Objects with positive "# Delta" (count increased) between snapshots are candidates for the leak.

Containment view: Shows the object reference tree. Trace from a suspected leaking object back to the GC root to understand why it is still in memory.

Retainers view: For a specific object, shows every other object that holds a reference to it. This tells you what is preventing garbage collection.

Common Memory Leak Patterns

1. Unbounded Caches

The most common Node.js memory leak:

// LEAK — cache grows forever
var cache = {};

function getUser(id) {
  if (cache[id]) return Promise.resolve(cache[id]);

  return db.query("SELECT * FROM users WHERE id = $1", [id])
    .then(function(result) {
      cache[id] = result.rows[0]; // Never evicted
      return result.rows[0];
    });
}

Fix: Add a size limit and expiration:

// FIXED — LRU cache with max size
function LRUCache(maxSize) {
  this.maxSize = maxSize;
  this.map = {};
  this.keys = [];
}

LRUCache.prototype.get = function(key) {
  var entry = this.map[key];
  if (!entry) return null;
  // Move to end (most recently used)
  var idx = this.keys.indexOf(key);
  this.keys.splice(idx, 1);
  this.keys.push(key);
  return entry;
};

LRUCache.prototype.set = function(key, value) {
  if (this.map[key]) {
    var idx = this.keys.indexOf(key);
    this.keys.splice(idx, 1);
  } else if (this.keys.length >= this.maxSize) {
    var oldest = this.keys.shift();
    delete this.map[oldest];
  }
  this.map[key] = value;
  this.keys.push(key);
};

var userCache = new LRUCache(1000);

2. Event Listener Accumulation

// LEAK — new listener added on every request, never removed
app.get("/api/stream", function(req, res) {
  function onData(data) {
    res.write(JSON.stringify(data) + "\n");
  }

  eventBus.on("update", onData);
  // Connection closes, but the listener stays
});

Fix: Remove listeners when the connection closes:

// FIXED
app.get("/api/stream", function(req, res) {
  function onData(data) {
    res.write(JSON.stringify(data) + "\n");
  }

  eventBus.on("update", onData);

  req.on("close", function() {
    eventBus.removeListener("update", onData);
  });
});

Detect listener leaks:

// Node.js warns when more than 10 listeners are added to an emitter
// Increase the threshold or investigate:
eventBus.setMaxListeners(20); // Only increase if you understand why

// Log listener counts
setInterval(function() {
  var count = eventBus.listenerCount("update");
  if (count > 10) {
    console.warn("Event listener accumulation:", count, "listeners on 'update'");
  }
}, 30000);

3. Closures Capturing Large Objects

// LEAK — closure holds reference to the entire response body
function createHandler(largeConfig) {
  return function(req, res) {
    // Only uses largeConfig.apiKey but the entire object stays in memory
    res.json({ key: largeConfig.apiKey });
  };
}

// FIXED — extract only what is needed
function createHandler(largeConfig) {
  var apiKey = largeConfig.apiKey; // Extract the value
  // largeConfig can now be garbage collected

  return function(req, res) {
    res.json({ key: apiKey });
  };
}

4. Global Variables

// LEAK — accidentally global (missing var)
function processRequest(req) {
  results = [];  // Missing var — creates a global variable
  req.body.items.forEach(function(item) {
    results.push(transform(item));
  });
  return results;
}

// FIXED
function processRequest(req) {
  var results = [];
  req.body.items.forEach(function(item) {
    results.push(transform(item));
  });
  return results;
}

Use strict mode to catch accidental globals:

"use strict";

function processRequest(req) {
  results = []; // ReferenceError: results is not defined
}

5. Unreleased Database Connections

// LEAK — client acquired but never released on error
function getUser(id) {
  return pool.connect()
    .then(function(client) {
      return client.query("SELECT * FROM users WHERE id = $1", [id])
        .then(function(result) {
          client.release();
          return result.rows[0];
        });
      // If query throws, client is never released
    });
}

// FIXED — always release in finally
function getUser(id) {
  var client;
  return pool.connect()
    .then(function(c) {
      client = c;
      return client.query("SELECT * FROM users WHERE id = $1", [id]);
    })
    .then(function(result) {
      return result.rows[0];
    })
    .finally(function() {
      if (client) client.release();
    });
}

// SIMPLEST — use pool.query which handles connection lifecycle
function getUser(id) {
  return pool.query("SELECT * FROM users WHERE id = $1", [id])
    .then(function(result) {
      return result.rows[0];
    });
}

6. Timer References

// LEAK — setInterval holds references to callback and its closure
function startPolling(resource) {
  var data = fetchInitialData(resource); // Large object

  setInterval(function() {
    checkForUpdates(data); // Closure holds 'data' forever
  }, 5000);
}

// FIXED — store interval ID and clear when done
function startPolling(resource) {
  var data = fetchInitialData(resource);
  var intervalId;

  intervalId = setInterval(function() {
    checkForUpdates(data);
  }, 5000);

  // Return cleanup function
  return function stop() {
    clearInterval(intervalId);
    data = null;
  };
}

var stopPolling = startPolling(myResource);
// Later:
stopPolling();

7. String Concatenation in Loops

// LEAK-LIKE — creates many intermediate strings
function buildReport(rows) {
  var output = "";
  for (var i = 0; i < rows.length; i++) {
    output += JSON.stringify(rows[i]) + "\n"; // New string object each iteration
  }
  return output;
}

// BETTER — use array join
function buildReport(rows) {
  var parts = [];
  for (var i = 0; i < rows.length; i++) {
    parts.push(JSON.stringify(rows[i]));
  }
  return parts.join("\n");
}

Production Monitoring

Memory Threshold Alerts

// monitor/memory.js
var WARNING_MB = 400;
var CRITICAL_MB = 600;
var MAX_MB = 700;

function checkMemory() {
  var heapUsed = process.memoryUsage().heapUsed;
  var heapMB = Math.round(heapUsed / 1024 / 1024);

  if (heapMB > MAX_MB) {
    console.error(JSON.stringify({
      level: "critical",
      type: "memory_critical",
      heapMB: heapMB,
      message: "Memory exceeds maximum threshold, restarting"
    }));
    process.exit(1); // Let PM2 restart the process
  }

  if (heapMB > CRITICAL_MB) {
    console.error(JSON.stringify({
      level: "error",
      type: "memory_high",
      heapMB: heapMB,
      message: "Memory usage critical"
    }));
  } else if (heapMB > WARNING_MB) {
    console.warn(JSON.stringify({
      level: "warn",
      type: "memory_warning",
      heapMB: heapMB,
      message: "Memory usage elevated"
    }));
  }
}

setInterval(checkMemory, 30000);

PM2 Memory Restart

// ecosystem.config.js
module.exports = {
  apps: [{
    name: "myapp",
    script: "server.js",
    max_memory_restart: "500M",  // Restart if process exceeds 500MB
    node_args: "--max-old-space-size=512"  // V8 heap limit
  }]
};

PM2 monitors memory usage and restarts the process when it exceeds the threshold. This is a safety net, not a fix — you should still find and fix the underlying leak.

Garbage Collection Monitoring

# Run with GC tracing
node --trace-gc server.js

Output:

[12345:0x3e2f0c0] 15234 ms: Scavenge 45.2 (52.0) -> 38.1 (53.0) MB, 2.1 / 0.0 ms
[12345:0x3e2f0c0] 16567 ms: Mark-sweep 51.3 (53.0) -> 42.7 (55.0) MB, 15.3 / 0.0 ms

Scavenge — young generation collection (fast, frequent) Mark-sweep — old generation collection (slower, less frequent)

If mark-sweep collections reclaim less and less memory over time, old generation objects are accumulating — a leak in long-lived objects.

Debugging Workflow

Step 1: Confirm the Leak

Monitor process.memoryUsage().heapUsed over time. If it grows steadily under consistent load, there is a leak. Temporary spikes during traffic bursts are normal.

Step 2: Reproduce

Create a test that exercises the suspected endpoint:

# Simple load test with curl
for i in $(seq 1 1000); do
  curl -s http://localhost:3000/api/articles > /dev/null
done

Step 3: Take Heap Snapshots

Use the three-snapshot technique:

  1. Baseline snapshot
  2. Run load test
  3. Force GC, take second snapshot
  4. Run load test again
  5. Force GC, take third snapshot

Step 4: Compare Snapshots

In Chrome DevTools, compare snapshots 2 and 3. Sort by "# Delta" to find object types with growing counts.

Step 5: Trace References

Select a leaked object and use the "Retainers" panel to trace what holds a reference to it. Follow the chain back to your code.

Step 6: Fix and Verify

Apply the fix and re-run the load test. Memory should stabilize instead of growing.

Common Issues and Troubleshooting

Heap snapshot is too large to load

The process has too many objects for Chrome DevTools:

Fix: Take snapshots earlier before the leak grows too large. Use --max-old-space-size to limit heap size. Filter the snapshot by constructor name to focus on specific object types.

Memory grows but heap snapshots show nothing unusual

The leak is in native (C++) objects, not JavaScript:

Fix: Check external in process.memoryUsage(). Native addons, Buffers, and streams may leak outside the V8 heap. Check for unclosed file descriptors, unclosed streams, or native module memory.

Memory usage is high but stable

Not a leak — the application genuinely needs that memory:

Fix: Check if the data set has grown. Verify cache sizes are appropriate. Consider whether the application needs more memory or if the data access pattern can be optimized.

GC pauses cause latency spikes

Major garbage collections pause the event loop:

Fix: Reduce heap size by fixing leaks and limiting caches. Smaller heaps have shorter GC pauses. Consider --max-old-space-size to cap heap growth. Use --trace-gc to correlate GC pauses with latency spikes.

Best Practices

  • Monitor memory usage in production. Track heapUsed over time. A steadily growing value that never decreases after GC is a leak.
  • Set memory limits. Use PM2's max_memory_restart and V8's --max-old-space-size. These prevent a single leak from crashing the server.
  • Use bounded data structures. Every cache, queue, and buffer should have a maximum size. Unbounded collections are the most common source of leaks.
  • Remove event listeners when done. Every on() must have a corresponding removeListener() or off() when the subscriber is destroyed.
  • Use pool.query instead of manual connect/release. Connection pool's query method handles the lifecycle automatically. Manual connect + release is error-prone.
  • Use strict mode. "use strict" catches accidental global variables that would otherwise leak memory silently.
  • Profile regularly. Take heap snapshots periodically in staging environments. Catch leaks before they reach production.
  • Test with sustained load. Memory leaks only appear under sustained traffic. A quick smoke test will not reveal a leak that accumulates over hours.

References

Powered by Contentful