Backend Development

Node.js Memory Leak in Production

A real production experience from a Senior Node.js Backend Engineer — identifying, analyzing, and fixing a critical memory leak that silently degraded performance and caused system crashes.

Node.js Memory Leak in Production

Introduction

In real-world Node.js backend systems, not all issues explode with clear stack traces. Some of the most dangerous problems grow silently over time.

You deploy your API. Everything looks stable. CPU is fine. Logs are clean.

Then slowly:

  • Memory usage increases
  • CPU starts spiking
  • Response time degrades
  • And suddenly… your process crashes

This is the nature of a Memory Leak in Node.js.

In this article, I’ll walk through a real production case, explain the exact root causes, and show practical, production-ready solutions.


System Context

  • Node.js (Express)
  • Prisma ORM
  • SQLite → PostgreSQL
  • PM2
  • Nginx
  • VPS (Ubuntu)

Traffic:

  • ~500–1500 req/min

Symptoms Observed

  • RAM: 300MB → 1.8GB
  • CPU spikes
  • Slow responses
  • Process restarts

Step 1: Confirming a Memory Leak

setInterval(() => {
  const used = process.memoryUsage();

console.log({
rss: used.rss,
heapTotal: used.heapTotal,
heapUsed: used.heapUsed,
external: used.external,
});
}, 5000);


Observation

  • <code>heapUsed</code> continuously increased
  • Garbage Collector did NOT free memory
  • No stabilization plateau

Conclusion:

This is a true memory leak

Step 2: Heap Analysis

node --inspect index.js
chrome://inspect
  • Open Memory tab
  • Take Heap Snapshots

Findings

  • Thousands of retained objects
  • Growing references
  • No garbage collection

Root Causes

Unbounded Cache

const cache = {};

Event Listener Leak

someEmitter.on('event', () => {});

Unresolved Promise

new Promise(() => {});

Production Fixes (Copy-Paste)

// LRU CACHE
import LRU from 'lru-cache';

const cache = new LRU({
max: 500,
ttl: 1000 60 5,
});

// EVENT CLEANUP
const handler = () => {};
someEmitter.on('event', handler);
someEmitter.off('event', handler);

// SAFE ASYNC
async function safeAsync() {
try {
return await someAsyncTask();
} catch (e) {
console.error(e);
}
}

// REDIS CACHE
import Redis from 'ioredis';

const redis = new Redis();

async function getData(key) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);

const data = await getDataFromDB(key);
await redis.set(key, JSON.stringify(data), 'EX', 300);

return data;
}

// STREAM
import fs from 'fs';

app.get('/file', (req, res) => {
fs.createReadStream('./large-file.txt').pipe(res);
});

// MONITOR
setInterval(() => {
console.log(process.memoryUsage().heapUsed);
}, 10000);


Results

| Metric | Before | After |
|------|------|------|
| RAM | 1.8GB | ~350MB |
| Stability | ❌ | ✅ |
| Crash | Yes | No |


Insights

  • Most leaks = bad references
  • GC depends on references
  • Global state = risk

Best Practices

  • Use LRU / Redis
  • Avoid global memory growth
  • Clean listeners
  • Resolve promises
  • Monitor memory

Monitoring

pm2 monit
pm2 install pm2-server-monit

Scaling

  • Redis
  • Queue (BullMQ)
  • Microservices
  • Observability

Final Thoughts

Memory leaks in Node.js are:

  • Silent
  • Dangerous
  • Preventable