Skip to main content

Command Palette

Search for a command to run...

My Node.js Server Was Leaking Memory in Production. Here's How I Found It.

Updated
9 min read
My Node.js Server Was Leaking Memory in Production. Here's How I Found It.
S
I'm a Full-Stack Developer with 3+ years of experience building scalable applications and solving real-world engineering challenges. Here, I write about software development, backend systems, DevOps, cloud technologies, system design, and lessons learned from building production-ready software.

Our Node.js server was crashing in production. Not once — every single instance was going down, one after another, and Sentry (logging) was flooding with alerts. We had auto-scaling configured with a minimum of 3 instances and a maximum of 10. So users weren't getting hit directly — as one instance crashed, another spun up to replace it. But every new instance had the same code, the same leak. It was a continuous cycle of crash and restart, just slow enough that users didn't feel it. When I finally looked at the error, it was a fatal one:

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

The Node.js process wasn't throwing an exception — it was hitting the V8 heap limit and dying. No graceful shutdown, no catch block. Just a crash. I knew it was a memory leak. What I didn't know was how to debug one. I had never been in this situation before. That incident forced me to actually understand how Node.js manages memory — how V8 allocates the heap, how garbage collection works, and how to track down exactly what is holding memory that should have been released. This is what I learned.


What is a memory leak

A memory leak happens when your application holds references to objects it no longer needs. The garbage collector cannot free them — because from its perspective, they are still in use.

The core insight: Garbage collector is not broken. Your code still holds the reference. That is the entire problem.

The result: heapUsed grows continuously. CPU spikes as GC runs more frequently trying to recover space. Eventually Node.js hits its heap limit — by default somewhere between 1.5 GB and 4 GB depending on the system and Node.js version — and throws :


How Node.js memory is structured

Stack memory

Stores primitive values, function call frames, and short-lived data. Automatically managed — no GC involvement. Very fast.

Heap memory

Stores objects, arrays, closures, and function objects. This is where all long-lived data lives — and where leaks happen.

Inspecting memory usage

Watch heapUsed over time. If it grows across requests and never fully recovers after garbage collection (GC) , you have a leak.

const mem = process.memoryUsage();
 
console.log({
  heapUsed:  mem.heapUsed,   // memory your app is actively using
  heapTotal: mem.heapTotal,  // total heap allocated by V8
  rss:       mem.rss,        // total resident set size (OS level)
  external:  mem.external    // C++ objects bound to JS objects
});

Garbage collection — Mark and Sweep

V8 uses a Mark and Sweep algorithm. It begins from a set of GC roots — the global scope, active function call stacks, and live closures — then traces every object reachable from those roots.

Step 1 — Mark

Starting from GC roots, V8 traverses the entire object graph and marks every reachable object as alive.

Step 2 — Sweep

Everything not marked is considered unreachable and is freed. The memory is returned to the heap allocator.

Why leaks still happen

A single reference from a GC root keeps an entire object graph alive. One forgotten closure, one lingering event listener — that is enough to prevent GC from cleaning up everything that object touches.

// GC can clean this — no references remain after reassignment
let user = { name: 'Ali' };
user = null;
 
// GC cannot clean this — the users array is a GC root reference
// Every request pushes a new object. Nothing removes them.
const users = [];
 
app.get('/', () => {
  users.push({ name: 'Ali' });
});

Common causes

1. Global variables

// Bad — grows for the entire process lifetime
global.cache = [];
app.get('/', () => global.cache.push(req.body));

Objects assigned to the global scope are GC roots. Any unbounded growth is permanent.


2. Event listeners never removed

emitter.on('data', processData);
 
// Good — remove when no longer needed
emitter.removeListener('data', processData);
// or
emitter.off('data', processData);

Each listener holds a reference to its callback and the closure scope around it.


3. Timers never cleared


const interval = setInterval(() => doSomething(), 1000);
 
// Good to clear otherwise the interval and its closure are never freed
clearInterval(interval);

A forgotten setInterval keeps its callback — and everything that callback closes over — alive forever.


4. Closures holding large outer-scope data

function outer() {
  const hugeData = new Array(1_000_000).fill('x');
 
  // inner holds a reference to the outer scope
  // hugeData cannot be freed as long as inner is reachable
  return function inner() {
    console.log('Hello');
  };
}

If inner is stored anywhere reachable (a global, a map, a callback), hugeData stays in memory.


5. Unbounded in-memory cache

// Bad — grows indefinitely
const cache = {};
app.get('/user/:id', async (req, res) => {
  cache[req.params.id] = await db.getUser(req.params.id);
  res.json(cache[req.params.id]);
});
 
// Good — use an LRU cache with a size limit
const LRU = require('lru-cache');
const cache = new LRU({ max: 500, ttl: 1000 * 60 * 5 });

6. Unclosed database connections and streams

// Bad — connection never released back to pool
app.get('/data', async (req, res) => {
  const conn = await pool.getConnection();
  const rows = await conn.query('SELECT * FROM users');
  res.json(rows);
  // conn.release() missing
});
 
// Good
app.get('/data', async (req, res) => {
  const conn = await pool.getConnection();
  try {
    const rows = await conn.query('SELECT * FROM users');
    res.json(rows);
  } finally {
    conn.release();
  }
});

How to detect and debug

The red flag — exponentially growing heap

Normal healthy pattern: memory rises, GC runs, memory drops back close to baseline.

Leak pattern: the baseline keeps rising with each GC cycle.

Snapshot A:  100 MB  ▓░░░░░░░░░
Snapshot B:  200 MB  ▓▓░░░░░░░░
Snapshot C:  350 MB  ▓▓▓▓░░░░░░
Snapshot D:  500 MB  ▓▓▓▓▓▓▓░░░
             ← GC ran but the floor keeps rising

Method 1 — Heap snapshot comparison (most effective)

This is the primary technique. Take a baseline snapshot, apply sustained load, take a second snapshot, compare.

Start Node with inspector:

node --inspect app.js

Open Chrome DevTools:

chrome://inspect → Open dedicated DevTools for Node
→ Memory tab → Take heap snapshot

What to look for:

Term What it means
Retained size How much memory stays alive because this object exists. High retained size = strong leak candidate.
Shallow size Memory the object itself uses, excluding what it references. Less useful for leak detection.
Detached objects Objects that should have been freed but still have at least one live reference. Filter the class list for (Detached).

Compare two snapshots:

In the Memory tab, use the comparison view. Sort by "Delta" — objects with a large positive delta are growing between snapshots.

Object Snapshot A Snapshot B Snapshot C
users [] 100 10,000 50,000
cache {} 200 8,400 41,200
EventEmitter listeners 4 4 4

The first two rows are clearly leaking. The listener count being stable is a good sign.


Method 2 — heapdump (programmatic snapshots)

Useful when you can't attach a debugger in production.

npm install heapdump
const heapdump = require('heapdump');
 
// Write a snapshot on demand
heapdump.writeSnapshot('./heap-' + Date.now() + '.heapsnapshot');
 
// Or trigger via signal
process.on('SIGUSR2', () => {
  heapdump.writeSnapshot('./heap-' + Date.now() + '.heapsnapshot');
  console.log('Heap snapshot written');
});

Send kill -USR2 <pid> to capture at any point. Open the .heapsnapshot file in Chrome DevTools → Memory → Load.


Method 3 — clinic.js

npm install -g clinic autocannon

clinic doctor -- node app.js

# In a separate terminal, generate load using autocannon
autocannon -c 10 -d 3 http://localhost:3000

Runs your app under a profiler and generates a browser-based report covering CPU, memory pressure, and event loop lag. Can surface memory issues automatically without manual heap snapshot analysis.

Also useful: clinic heapprofiler for allocation-level profiling.


Prevention checklist

  • Clear all setInterval and setTimeout when no longer needed

  • Remove event listeners with emitter.off() when the listener is done

  • Never grow global objects without a corresponding removal path

  • Use LRU caching with a bounded size — never a plain unbounded object

  • Close DB connections, file streams, and sockets in finally blocks

  • Audit closures that reference large outer-scope objects

  • Monitor heapUsed in production — alert if it exceeds a threshold

  • Compare heap snapshots before and after sustained load, not just at startup


Conclusion

A memory leak is not Node.js forgetting to clean up. It is your application still holding a reference to something it no longer uses. GC is working correctly.

memory leak = unwanted reference staying reachable from a GC root

Find the reference. Remove it. The GC handles the rest.


Further Reading

Code & Walkthroughs

External