0x55aa
← Back to Blog

Your Node.js Server is Bleeding Memory (And How to Stop It) 🩸

6 min read

Your Node.js Server is Bleeding Memory (And How to Stop It) 🩸

Picture this: it's 3 AM. Your on-call phone buzzes. The server is down. You check the logs and see that the process was using 4GB of RAM before the OS unceremoniously executed it. Everything worked fine yesterday. Nothing changed. What happened?

Memory leak. The silent killer of Node.js apps.

What Even Is a Memory Leak?

Think of your server's memory like a whiteboard. When your code needs something — a variable, an object, a cached result — it writes it on the whiteboard. When that thing is no longer needed, JavaScript's garbage collector (GC) wipes it off.

A memory leak happens when stuff stays on the whiteboard forever, even though nobody's using it anymore. The GC can't erase it because something — somewhere — still holds a reference to it. Over time, the whiteboard fills up. Your process runs out of RAM. The OS kills it. Your 3 AM begins.

The sneaky part? Your server might handle requests perfectly for hours, days, even weeks — and then slowly, gradually, start slowing down before dying. It's carbon monoxide poisoning, not a gunshot.

The Usual Suspects

1. Growing Arrays and Maps Nobody Cleans Up

This one's embarrassingly common:

const requestLog = [];

app.use((req, res, next) => {
  // "For debugging purposes"
  requestLog.push({ url: req.url, time: Date.now(), headers: req.headers });
  next();
});

Seems innocent. You get 10,000 requests per day. After a week, requestLog has 70,000 entries sitting in memory. After a month? Half a million. That's hundreds of megabytes of request data nobody is reading.

The fix is dead simple — cap the array or use a proper logging library:

const requestLog = [];
const MAX_LOG_SIZE = 1000;

app.use((req, res, next) => {
  requestLog.push({ url: req.url, time: Date.now() });
  if (requestLog.length > MAX_LOG_SIZE) {
    requestLog.shift(); // drop the oldest entry
  }
  next();
});

Better yet: use winston or pino and write to a file or log aggregator. In-memory logging is almost always a mistake.

2. Event Listeners That Never Die

Node.js is built on events, and that's where a lot of leaks hide:

function setupWebSocket(socket) {
  // This listener is added on EVERY connection...
  process.on('SIGTERM', () => {
    socket.close();
  });
}

Every new WebSocket connection registers another SIGTERM listener on process. After 1,000 connections, you have 1,000 listeners all pointing to closed sockets — but process still holds references to all of them. Node.js even warns you: MaxListenersExceededWarning: Possible EventEmitter memory leak detected.

The fix: remove listeners when they're no longer needed:

function setupWebSocket(socket) {
  const cleanup = () => socket.close();

  process.on('SIGTERM', cleanup);

  // Clean up when the socket closes
  socket.on('close', () => {
    process.removeListener('SIGTERM', cleanup);
  });
}

3. Closures Accidentally Holding Giant References

Closures are elegant. Closures are powerful. Closures will hold onto memory you forgot existed:

function processLargeFile(buffer) {
  const summary = buffer.slice(0, 100).toString(); // just need first 100 bytes

  return function getSummary() {
    return summary; // but the closure captured `buffer` too!
  };
}

In many JS engines, even if you only use summary inside the returned function, the entire buffer might stay in memory because the closure closed over the outer scope. With large buffers, this is brutal.

Fix: be explicit about what you need:

function processLargeFile(buffer) {
  const summary = buffer.slice(0, 100).toString();
  buffer = null; // explicitly release the reference

  return function getSummary() {
    return summary;
  };
}

How to Actually Find Leaks in Production

Knowing the theory is great. Finding the actual leak in your 50,000-line codebase is another matter.

Step 1: Confirm it's a leak, not a spike.

Watch memory over time with a tool like clinic.js or just expose a /health endpoint that reports process.memoryUsage(). If heapUsed grows steadily and never comes back down even during idle periods, you have a leak.

Step 2: Take heap snapshots.

The V8 inspector (built into Node.js) lets you take heap snapshots:

node --inspect your-app.js

Open Chrome DevTools, connect to chrome://inspect, and use the Memory tab. Take a snapshot before a load test, run the test, take another snapshot. Compare them. Look for objects that are growing — especially Array, Map, Set, EventEmitter, and Closure.

Step 3: Look at what's retaining those objects.

The snapshot comparison will show you what's growing. Click on any growing object type and Chrome will show you the retention path — the chain of references keeping it alive. Follow the chain backwards to find the root cause.

Step 4: Fix, deploy, verify.

Obvious, but don't skip the "verify" part. Memory leaks are infamously hard to reproduce consistently. Monitor your heap over 24-48 hours after the fix to confirm the growth pattern is gone.

Quick Wins Before You Even Debug

Before diving into heap snapshots, check these boxes:

  • Set --max-old-space-size explicitly (e.g., node --max-old-space-size=512 app.js). This forces the GC to work harder and surfaces leaks faster instead of letting memory balloon silently.
  • Use WeakMap and WeakSet for caches that map objects to data. Unlike Map, weak collections don't prevent GC from collecting their keys.
  • Audit your third-party libraries. Some popular packages have known memory leak issues. Check their GitHub issues page before assuming your code is the problem.
  • Restart on a schedule as a stopgap. Not a fix, but PM2's --max-memory-restart flag will restart your process if it exceeds a memory threshold. Buys you time while you hunt the real culprit.

The Takeaway

Memory leaks aren't exotic bugs reserved for C programmers. They happen to everyone writing Node.js, and they're almost always caused by one of three things: unbounded collections, forgotten event listeners, or accidental closure captures.

The good news: once you know what to look for, they're surprisingly findable. The V8 inspector + Chrome DevTools is one of the most underrated debugging combos in the Node.js ecosystem, and it's completely free.

Start monitoring your heap usage today — not when production catches fire at 3 AM. Your future on-call self will thank you.


Have you tracked down a particularly nasty memory leak? Drop it in the comments — the weirder the better. The most creative leak story wins eternal developer street cred.