0x55aa
Back to Blog

🧠 Node.js Memory Leaks: Your Server Is Eating RAM for Breakfast

|
6 min read

🧠 Node.js Memory Leaks: Your Server Is Eating RAM for Breakfast

Picture this: you deploy your shiny Node.js app on Monday. Everything is fast, memory is stable, your team high-fives. By Wednesday, it's using 2GB of RAM. By Friday, it crashes, your PM is angry, and you're reading Stack Overflow at 2am wondering where your life went wrong.

Welcome to memory leaks — the silent assassins of backend development.

The tricky part? Memory leaks in Node.js don't announce themselves with a big error. They just... accumulate. Like that pile of laundry you keep telling yourself you'll deal with later.

Let's talk about how they happen, how to spot them, and how to fix them before they ruin your weekend.


What Even Is a Memory Leak in Node.js?

Node.js uses V8's garbage collector, which automatically cleans up objects that are no longer referenced. A memory leak happens when you accidentally keep references to objects you don't need anymore — so the GC politely leaves them alone, and they pile up forever.

Think of it like a restaurant where the waiter keeps every dirty dish "just in case." The kitchen fills up. Eventually, nothing works.


The Classic Culprit: Unbounded Caches

The number one cause of memory leaks I've seen in production? In-memory caches with no eviction policy.

// 💀 This will eat your RAM alive
const cache = {};

app.get('/user/:id', async (req, res) => {
  const { id } = req.params;

  if (!cache[id]) {
    cache[id] = await db.getUser(id);
  }

  res.json(cache[id]);
});

Looks innocent, right? But if you have 100,000 unique users, that cache object grows to 100,000 entries and never shrinks. Your server is now a data hoarder.

The fix: Use a proper LRU (Least Recently Used) cache with a size limit.

const LRU = require('lru-cache');

const cache = new LRU({
  max: 500,           // store at most 500 items
  ttl: 1000 * 60 * 5 // expire after 5 minutes
});

app.get('/user/:id', async (req, res) => {
  const { id } = req.params;

  let user = cache.get(id);
  if (!user) {
    user = await db.getUser(id);
    cache.set(id, user);
  }

  res.json(user);
});

Now your cache has a memory budget. It evicts old entries automatically. Crisis averted, weekends saved.


The Sneaky Culprit: Event Listener Accumulation

Node.js is event-driven, which is fantastic — until you forget to remove listeners and they stack up like browser tabs you swear you'll close later.

// 💀 A new listener added on every request — oops
app.get('/start-job', (req, res) => {
  const job = new JobProcessor();

  // This listener is NEVER removed
  job.on('progress', (pct) => {
    console.log(`Progress: ${pct}%`);
  });

  job.start();
  res.json({ started: true });
});

Every request to /start-job creates a JobProcessor and attaches a listener. Even after the job finishes, if job is referenced anywhere (or the emitter itself isn't GC'd), those listeners pile up. Node.js will even warn you: MaxListenersExceededWarning — that's your server quietly screaming.

The fix: Remove listeners when you're done, or use .once() for single-fire events.

app.get('/start-job', (req, res) => {
  const job = new JobProcessor();

  const onProgress = (pct) => {
    console.log(`Progress: ${pct}%`);
  };

  job.on('progress', onProgress);

  job.on('done', () => {
    job.off('progress', onProgress); // clean up
  });

  job.start();
  res.json({ started: true });
});

How to Actually Find a Memory Leak

Knowing the patterns is half the battle. The other half is catching leaks in the wild. Here's the practical toolkit:

1. Watch your heap over time

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`,
  });
}, 10000);

If heapUsed keeps climbing without coming back down, you have a leak. This is your early warning system — cheap, built-in, zero dependencies.

2. Take heap snapshots with Chrome DevTools

Start your app with node --inspect app.js, open chrome://inspect in Chrome, and use the Memory tab to take heap snapshots before and after load. Compare them to find what's accumulating.

3. Use clinic.js for automated profiling

npm install -g clinic
clinic heapprofiler -- node app.js

clinic runs your app, hammers it with load, and generates a beautiful flame graph showing exactly where memory is being allocated. It's like having a detective who actually does the work.


The Forgotten Leak: Closures Holding References

This one bites even experienced developers. Closures in JavaScript capture variables from their outer scope — including large objects you thought were done with.

// 💀 The `bigData` array is captured by the closure
//    and never freed as long as `processLater` lives
function createHandler(bigData) {
  return function processLater() {
    // only uses bigData.id, but holds the whole thing
    console.log(bigData.id);
  };
}

If processLater is stored in a long-lived structure (like a queue), bigData — potentially megabytes of data — gets dragged along for the ride.

The fix: extract only what you need before creating the closure.

function createHandler(bigData) {
  const id = bigData.id; // extract just what we need
  return function processLater() {
    console.log(id); // bigData is now free to be GC'd
  };
}

Quick Leak-Prevention Checklist

Before you ship, run through these:

  • Caches have a max size or TTL
  • Event listeners are cleaned up after use
  • Intervals/timeouts are cleared when no longer needed (clearInterval, clearTimeout)
  • Database connections are properly closed or returned to the pool
  • Large objects in closures are trimmed to only what's needed
  • Streams are destroyed/closed on error, not just on success

The Bottom Line

Memory leaks in Node.js are rarely dramatic — they're a slow, quiet degradation. The server that was snappy on Monday becomes a molasses-covered disaster by Thursday.

The good news: most leaks fall into a handful of predictable patterns. Unbounded caches, orphaned event listeners, and closures that hold more than they should account for the vast majority of what you'll encounter in the real world.

Add process.memoryUsage() logging today. Seriously, right now. It takes five minutes and gives you visibility that will save you hours of weekend debugging. If the number only goes up and never comes down, you've got work to do.

Now go audit those caches. Your ops team will thank you.


Found a sneaky memory leak pattern I didn't cover? Hit me up — I'm always collecting new ways servers can quietly lose their minds.

Thanks for reading!

Back to all posts