Memory Leak in Node.js: How I Fixed a Massive One in Production

Meta description: I tracked down and fixed a critical memory leak in a Node.js production app crashing every 6 hours. Here’s every step I took, the exact tools I used, and what finally solved it.

Last updated: May 24, 2025


Introduction

At 3 a.m. on a Tuesday, my phone started blowing up. Our Node.js API — serving roughly 40,000 requests per hour — was crashing every six hours like clockwork. The PM2 logs were showing JavaScript heap out of memory, and each server restart was just a band-aid on a wound I hadn’t found yet.

I spent the next three days deep inside heap snapshots, event loop monitors, and V8 internals. What I discovered wasn’t a single smoking gun — it was a combination of three silent bugs that had been slowly eating RAM since our last deployment.

In this article, I’ll walk you through exactly how I diagnosed and fixed a Node.js memory leak in a production Express.js application running Node 18.16.0. Every command, every tool, every gotcha is in here.


TL;DR

  • Heap snapshots taken with node --inspect and Chrome DevTools revealed objects that were never being garbage collected.
  • The root cause was a mix of event listener accumulation, an unclosed database connection pool, and a global cache with no eviction policy.
  • Fixing all three brought heap usage from a steady climb to ~180 MB to a flat line around 120 MB.

Background / Why Node.js Memory Leaks Are Especially Tricky

Node.js uses the V8 garbage collector, which manages memory automatically — so in theory, you shouldn’t have to think about it. In practice, the GC can only free memory that has no living references. If any part of your code is holding a reference to an object, that object stays in memory forever.

The insidious part is that leaks often don’t show up in development. They accumulate slowly under real traffic, which is why production is usually where you first notice them. By then, diagnosing the issue requires real production-like data and a clear process.

Understanding JavaScript heap memory, event emitter leaks, and closure scope retention is essential before you can fix anything. [INTERNAL LINK: related article on Node.js performance optimization]


Prerequisites

Before following this guide, you should have:

  • Node.js 16+ installed (I used Node 18.16.0 LTS)
  • Access to production logs or a staging environment that mirrors production traffic
  • Basic familiarity with Chrome DevTools
  • clinic.js installed globally (npm install -g clinic)
  • heapdump or Node’s built-in --inspect flag available

Step-by-Step: How I Diagnosed and Fixed the Leak

Step 1: Confirm There Is Actually a Leak

Before assuming a leak, I ruled out normal memory growth. I added a quick memory monitor to log heap usage every 30 seconds:

setInterval(() => {
  const mem = process.memoryUsage();
  console.log({
    heapUsed: `${Math.round(mem.heapUsed / 1024 / 1024)} MB`,
    heapTotal: `${Math.round(mem.heapTotal / 1024 / 1024)} MB`,
    rss: `${Math.round(mem.rss / 1024 / 1024)} MB`,
  });
}, 30000);

After 90 minutes of traffic, heapUsed went from 120 MB to 310 MB with no plateau. That’s a leak — not just GC lag.

Step 2: Take Heap Snapshots with Chrome DevTools

I started the app with the --inspect flag to enable the remote debugger:

node --inspect=0.0.0.0:9229 server.js

Then I opened chrome://inspect in Chrome, connected to the remote target, and took three heap snapshots: at startup, after 30 minutes, and after 60 minutes. The key is the Comparison view — it shows exactly which object types grew between snapshots.

The first thing I saw: EventEmitter instances had increased by 1,400 objects between snapshot 1 and snapshot 3. That was my first lead.

Step 3: Identify Event Listener Accumulation

I traced the EventEmitter growth back to a middleware that was attaching a close listener to the request object on every request — but never removing it:

// BAD — this was inside a middleware, called on every request
req.on('close', () => {
  cache.invalidate(req.sessionId);
});

Because req objects are long-lived in our connection pool setup, those listeners were stacking up. Node.js actually warns about this:

MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
11 close listeners added to [IncomingMessage].

I had been ignoring this warning in our logs. Don’t ignore it.

The fix was to use req.once instead of req.on and to ensure the listener was removed after firing:

// GOOD — listener fires once and is automatically removed
req.once('close', () => {
  cache.invalidate(req.sessionId);
});

Step 4: Find and Fix the Unclosed Connection Pool

After fixing the event listener issue, the leak slowed — but didn’t stop. Back in the heap snapshot comparison, I found a large number of Pool and PoolConnection objects from our MySQL library (mysql2@3.3.3) that were never being released.

The bug was subtle. In one error-handling path inside a route, we were throwing before calling connection.release():

// BAD — connection never released on error
async function getUserData(userId) {
  const connection = await pool.getConnection();
  const result = await connection.query(/* ... */); // throws here sometimes
  connection.release(); // never reached if query throws
  return result;
}

The fix was wrapping the logic in a try/finally block to guarantee the connection was always released:

// GOOD — connection always released
async function getUserData(userId) {
  const connection = await pool.getConnection();
  try {
    const result = await connection.query(/* ... */);
    return result;
  } finally {
    connection.release();
  }
}

This is a classic gotcha with connection pools. If you’re using async/await, always use try/finally for resource cleanup.

Step 5: Add an Eviction Policy to the Global Cache

The third culprit was our in-memory cache — a plain JavaScript Map that we used to store processed API responses. It had no TTL, no max size, and no eviction logic. After a week of traffic, it was holding ~80,000 entries.

I replaced it with lru-cache@10.2.0, which handles eviction automatically:

import { LRUCache } from 'lru-cache';

const cache = new LRUCache({
  max: 5000,        // Max 5,000 entries
  ttl: 1000 * 60 * 5, // 5-minute TTL per entry
});

[SOURCE: https://github.com/isaacs/node-lru-cache]

After deploying all three fixes, heap usage stabilized at ~120 MB under full production traffic and never exceeded 160 MB over a 72-hour monitoring window.


Real-World Tips I Use in Production

Use clinic.js for flame graphs. Running clinic flame -- node server.js generates a visual flame graph that shows CPU and memory hotspots in a browser-readable format. I now run this before every major release.

Set --max-old-space-size as a safety net, not a solution. Setting NODE_OPTIONS=--max-old-space-size=512 buys you time, but it doesn’t fix the underlying leak. Use it to prevent crashes while you investigate.

Enable process.on('warning') in production. Node.js emits warnings for MaxListenersExceeded and other memory-related issues. Log those warnings to your observability stack (Datadog, New Relic, etc.) so you catch them before they become outages.

Pro Tip: Run node --expose-gc server.js in staging and call global.gc() manually before taking heap snapshots. This forces a full garbage collection cycle and gives you cleaner baseline data — you’ll only see objects that genuinely survived GC, not just objects that haven’t been collected yet.


Common Errors and How I Fixed Them

JavaScript heap out of memory — the most common symptom. First action: add the memory logging snippet from Step 1 to confirm the heap is growing over time, not just spiking.

MaxListenersExceededWarning — almost always means you’re attaching listeners in a loop or inside a request handler without removing them. Audit every .on() call in your codebase and ask: “Is this inside a function that runs repeatedly?”

Heap snapshots showing (array) or (compiled code) dominating memory — this usually points to a module-level cache or memoization function that has no bounds. Check for Map, Set, or plain objects used as caches without a size limit.

pool.getConnection() hanging indefinitely — this happens when your pool is exhausted because connections were never released. Set waitForConnections: true and connectionLimit: 10 in your pool config, and always use try/finally as shown in Step 4.

[SOURCE: https://nodejs.org/en/docs/guides/diagnostics/memory/using-heap-snapshot]


FAQ

Q: How do I find a memory leak in a Node.js application running in production?

A: The safest approach in production is to use Node’s built-in --inspect flag with a remote debugger (Chrome DevTools or ndb) and take heap snapshots at multiple intervals. Compare snapshots using the “Comparison” view to find object types that keep growing. Alternatively, use clinic.js or node-heapdump to generate snapshots without attaching a debugger.

Q: What are the most common causes of memory leaks in Node.js?

A: The most common causes are: accumulating event listeners that are never removed (especially inside req.on() or emitter.on() in request handlers), unclosed database or file descriptors in error paths, global caches (plain Map or Object) with no eviction policy, closures that unintentionally retain large outer scope variables, and timers (setInterval) that are never cleared.

Q: How does lru-cache help prevent memory leaks in Node.js?

A: lru-cache enforces a maximum number of entries (max) and/or a time-to-live (ttl) per entry. When the cache is full, it automatically evicts the least recently used item. This gives you the performance benefit of in-memory caching without the risk of unbounded memory growth over time.

Q: What is the difference between heapUsed and rss in process.memoryUsage()?

A: heapUsed is the amount of the V8 heap currently occupied by JavaScript objects. rss (Resident Set Size) is the total memory the Node.js process is using, including the heap, stack, and C++ bindings. When diagnosing leaks, focus on heapUsed first. If rss is growing but heapUsed is stable, the leak may be in a native addon or the V8 heap compaction isn’t running.

Q: How do I prevent memory leaks in Node.js Express middleware?

A: Avoid attaching event listeners to request or response objects inside middleware unless you explicitly remove them with .removeListener() or use .once(). Never store request-scoped data in module-level variables or closures that outlive the request. Always close streams, release database connections, and clear timers in cleanup code, ideally using try/finally blocks.


Conclusion

A Node.js memory leak is one of the hardest production bugs to diagnose because it hides under normal operation until it’s too late. The process I follow every time is: confirm growth with process.memoryUsage(), isolate the leak category with heap snapshot comparisons, then fix each root cause with proper resource cleanup.


About the Author

I’m a senior backend engineer with over eight years of experience building high-traffic Node.js and TypeScript services. I’ve worked across the full stack — from Express and Fastify APIs to PostgreSQL and Redis data layers — and I’ve shipped features and fixes at companies ranging from early-stage startups to teams serving millions of daily active users. When I’m not hunting memory leaks, I’m writing about the real, messy side of backend development that most tutorials skip over.