DEV Community

Cover image for How to Detect and Fix Node.js Memory Leaks in Production (Step-by-Step Guide)
Mohamed Bouhachimi
Mohamed Bouhachimi

Posted on Originally published at mohamedbouhachimi.hashnode.dev

How to Detect and Fix Node.js Memory Leaks in Production (Step-by-Step Guide)

Node.js is renowned for its high performance, event-driven architecture, and non-blocking I/O operations. Powered by Google Chrome's V8 JavaScript engine, it enables developers to build scalable, real-time web applications. However, operating Node.js applications in production introduces a critical operational challenge: memory leaks.

A memory leak occurs when an application retains references to objects that are no longer needed. Because the V8 Garbage Collector (GC) cannot identify these unused objects as freeable memory, the overall memory footprint grows over time. Eventually, this leads to performance degradation, high latency, and the infamous crash error:

FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory

In this comprehensive guide, we will break down how Node.js manages memory, analyze the primary causes of leaks in production, explore step-by-step diagnostic techniques using built-in tools, and establish actionable solutions to prevent memory growth.


Understanding V8 Memory Architecture in Node.js

To fix memory leaks effectively, you must understand how Node.js organizes process memory. Memory allocated to a Node.js process is divided into two primary categories: Resident Set Size (RSS) and JavaScript Heap.

1. Resident Set Size (RSS)

RSS represents the total portion of RAM allocated to the Node.js process in the host system. It consists of:

  • C++ Node.js Bindings: Internal engine structures.
  • Code Segment: The actual executing JavaScript code.
  • Stack: Local variables and primitive types.
  • Heap: Reference types, objects, arrays, and closures.

2. The V8 Heap Structure

The Heap is managed directly by the V8 garbage collector and is further divided into two generations:

  • New Space (Young Generation): Where new allocations occur. Objects here are short-lived and frequently garbage-collected using a fast Scavenge algorithm.
  • Old Space (Old Generation): Objects that survive multiple garbage collection cycles in the New Space are promoted to the Old Space. This space is collected less frequently using the Mark-Sweep-Compact algorithm.

When a memory leak occurs, it almost always manifests within the Old Space Heap.


The 4 Most Common Causes of Node.js Memory Leaks

1. Unintentional Global Variables

Global variables in Node.js stay alive for the entire lifecycle of the process. If you inadvertently attach data to the global object or assign values without specifying const, let, or var, that data will never be collected.

// BAD PRACTICE: Global leaks
function processUserData(user) {
    // Missing declaration creates a global variable
    userCache = userCache || [];
    userCache.push(user);
}
Enter fullscreen mode Exit fullscreen mode

Solution: Always enforce strict mode ('use strict';) at the top of your files or use linters like ESLint to catch undeclared variables before deployment.


2. Forgotten Event Listeners & EventEmitters

In Node.js, EventEmitter instances are widely used. If you attach event listeners to long-lived objects (such as process or singletons) without removing them when the associated request or task ends, the references remain indefinitely.

// BAD PRACTICE: Listener leak
const EventEmitter = require('events');
const globalEmitter = new EventEmitter();

function handleUserRequest(req, res) {
  globalEmitter.on('update', () => {
    res.send('Updated data'); // 'res' object is retained in memory!
  });
}
Enter fullscreen mode Exit fullscreen mode
// GOOD PRACTICE: Removing listeners
function handleUserRequest(req, res) {
  const onUpdate = () => res.send('Updated data');
  globalEmitter.on('update', onUpdate);

  res.on('finish', () => {
    globalEmitter.off('update', onUpdate);
  });
}
Enter fullscreen mode Exit fullscreen mode

3. Closures Retaining Outer Scope References

Closures are a powerful JavaScript feature, but they hold references to variables in their parent scope. If a long-lived closure references an outer variable containing large datasets, those datasets cannot be garbage-collected.

// BAD PRACTICE: Closure retaining scope
let unreferencedScopeHolder = null;

function replaceThing() {
  const originalThing = unreferencedScopeHolder;
  const unused = function () {
    if (originalThing) console.log("Hi");
  };

  unreferencedScopeHolder = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {}
  };
}

setInterval(replaceThing, 1000); // Heap growth accelerates continuously!
Enter fullscreen mode Exit fullscreen mode

4. Unbounded In-Memory Caching

Using plain JavaScript objects or arrays as an in-memory cache without an eviction strategy (such as Time-To-Live or maximum size limits) will steadily consume available memory.

Solution: Replace plain object caches with specialized caching libraries like lru-cache, or offload caching entirely to distributed systems like Redis or Memcached.


Step-by-Step: Diagnosing Memory Leaks in Production

Step 1: Programmatic Heap Tracking

You can monitor memory consumption directly in your code using process.memoryUsage().

function logMemoryUsage() {
  const memory = process.memoryUsage();
  console.log({
    rss: `${(memory.rss / 1024 / 1024).toFixed(2)} MB`,
    heapTotal: `${(memory.heapTotal / 1024 / 1024).toFixed(2)} MB`,
    heapUsed: `${(memory.heapUsed / 1024 / 1024).toFixed(2)} MB`,
    external: `${(memory.external / 1024 / 1024).toFixed(2)} MB`,
  });
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Generating Heap Snapshots

To inspect exact memory allocation, generate .heapsnapshot files using Node's built-in Inspector:

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

function takeHeapSnapshot(fileName) {
  const snapshotStream = v8.getHeapSnapshot();
  const fileStream = fs.createWriteStream(fileName);
  snapshotStream.pipe(fileStream);
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Analyzing Snapshots in Chrome DevTools

  1. Open Chrome and navigate to chrome://inspect.
  2. Click Open dedicated DevTools for Node.
  3. Go to the Memory tab, select Load, and upload your saved .heapsnapshot files.
  4. Compare two snapshots taken at different times using the Comparison view to spot growing object constructors.

Conclusion

Detecting and fixing memory leaks in Node.js requires a structured approach: understanding the V8 heap, identifying common bad practices, and taking regular heap snapshots to locate leaking references. By implementing strict scoping, proper event listener cleanups, and bounded caching strategies, you can maintain long-term stability and high performance in production.

If you found this guide helpful, consider reposting it to help other developers, and follow for more in-depth Node.js and backend engineering content!
​If you'd like to support my work and keep tutorials like this coming, you can buy me a coffee here:
πŸ‘‰ https://ko-fi.com/mohamedbouhachimi

Top comments (0)