How to Debug Node.js Memory Leaks (Step-by-Step Guide)
You've deployed your Node.js app, it runs fine for a few hours, then the container gets OOM-killed at 3 AM. Sound familiar? Memory leaks in Node.js are one of the most frustrating production issues because they're silent — everything works until it suddenly doesn't.
This guide walks through the exact process I use to find and fix memory leaks in Node.js applications. No theory-heavy fluff — just the tools, techniques, and patterns that actually work.
TL;DR — Key Takeaways
- Most Node.js memory leaks come from five sources: event listener accumulation, global variable caching, closures retaining references, uncleared timers, and forgotten streams.
- Heap snapshots via Chrome DevTools are the single most effective debugging tool — take three snapshots over time and compare allocations.
- clinic.js can detect a memory leak in under 60 seconds with zero code changes.
- A healthy Node.js process should have stable RSS memory after reaching steady state — if RSS grows linearly over time, you have a leak.
- V8's default heap limit is approximately 1.5 GB on 64-bit systems (configurable via
--max-old-space-size).
What Causes Memory Leaks in Node.js?
A memory leak occurs when your application allocates memory that the V8 garbage collector can never reclaim. In Node.js, the garbage collector uses a generational strategy — short-lived objects are collected quickly in the "new space," while long-lived objects get promoted to "old space" where collection is less frequent and more expensive.
The five most common causes of Node.js memory leaks, in order of frequency, are:
- Event listeners that accumulate — adding listeners in a request handler without removing them
- Unbounded caches — objects or Maps that grow indefinitely without eviction
- Closures holding references — callbacks that capture large objects and are never released
- Uncleared timers and intervals —
setIntervalorsetTimeoutreferences that persist - Stream backpressure issues — writable streams that aren't consumed, causing buffers to grow
If you're working with large file processing, stream-related leaks are especially common and worth understanding deeply.
Step 1: Confirm You Actually Have a Memory Leak
Before diving into heap snapshots, confirm the leak exists. Not every increase in memory usage is a leak — Node.js legitimately caches compiled code, and V8's GC doesn't always return memory to the OS immediately.
Add basic memory monitoring to your app:
setInterval(() => {
const usage = process.memoryUsage();
console.log({
rss: `${Math.round(usage.rss / 1024 / 1024)} MB`,
heapUsed: `${Math.round(usage.heapUsed / 1024 / 1024)} MB`,
heapTotal: `${Math.round(usage.heapTotal / 1024 / 1024)} MB`,
external: `${Math.round(usage.external / 1024 / 1024)} MB`,
});
}, 10000);
Run your app under load for 10-15 minutes. A healthy app's heapUsed will fluctuate in a sawtooth pattern (allocate, GC collects, allocate again) but stay within a bounded range. A leaking app's heapUsed will trend upward consistently.
Key metric: If heapUsed grows by more than 10-20 MB over 10 minutes under steady load, you likely have a leak.
You can also force a garbage collection to distinguish real leaks from lazy GC behavior:
node --expose-gc your-app.js
if (global.gc) {
global.gc();
console.log('Heap after forced GC:', process.memoryUsage().heapUsed);
}
If memory stays high after a forced GC, the leak is confirmed.
Step 2: Use clinic.js for Fast Detection
clinic.js is the fastest way to identify the type of memory issue you're dealing with. It wraps your Node.js process and generates a visual report with zero code changes.
Install it globally:
npm install -g clinic
Run your app through clinic doctor:
clinic doctor -- node server.js
Then hit your app with load. I typically use autocannon for this:
npx autocannon -c 100 -d 60 http://localhost:3000/api/your-endpoint
After the run, clinic generates an HTML report that clearly flags memory issues with a red indicator. The report shows whether the problem is memory-related, CPU-related, or I/O-related — which saves you from chasing the wrong thing entirely.
For deeper heap analysis, use clinic heapprofiler:
clinic heapprofiler -- node server.js
This gives you a flame chart of allocations, showing which functions are allocating the most memory that isn't being freed.
Step 3: Take Heap Snapshots with Chrome DevTools
Heap snapshots are the definitive way to find what's leaking. The technique is simple: take multiple snapshots over time and compare what's growing.
Start your app with the inspector:
node --inspect server.js
Open Chrome and navigate to chrome://inspect. Click "inspect" on your Node.js process. Go to the "Memory" tab.
The three-snapshot technique:
- Take Snapshot 1 — baseline after the app has started
- Run your suspected leaking operation 50-100 times
- Take Snapshot 2
- Run the operation another 50-100 times
- Take Snapshot 3
Now select Snapshot 3, change the view from "Summary" to "Comparison," and compare against Snapshot 1. Sort by "Size Delta" descending. The objects at the top with the largest positive delta are your prime suspects.
Look for:
- Strings — often indicate accumulated log entries or cached responses
- Arrays — usually a growing list that's never pruned
- Objects with familiar constructor names from your codebase
- (closure) entries — functions capturing references they shouldn't
Click on any suspicious object to see its "Retainers" — the chain of references keeping it alive. Follow this chain back to your code to find the root cause.
Step 4: Common Leak Patterns and How to Fix Them
The Event Listener Leak
This is the most common leak I see in production. It typically looks like this:
// LEAKY: new listener added on every request
app.get('/data', (req, res) => {
emitter.on('update', (data) => {
// This listener is never removed
console.log(data);
});
res.json({ ok: true });
});
Every request adds a new listener that's never cleaned up. After 10,000 requests, you have 10,000 listeners — each holding a closure reference.
Fix:
// FIXED: use once() or manage listener lifecycle
app.get('/data', (req, res) => {
emitter.once('update', (data) => {
console.log(data);
});
res.json({ ok: true });
});
Node.js will warn you when an emitter exceeds 10 listeners (the MaxListenersExceededWarning). Never suppress this warning by increasing the limit without understanding why it's triggering.
The Unbounded Cache Leak
// LEAKY: cache grows forever
const cache = {};
app.get('/user/:id', async (req, res) => {
if (!cache[req.params.id]) {
cache[req.params.id] = await db.getUser(req.params.id);
}
res.json(cache[req.params.id]);
});
Fix: Use an LRU cache with a max size, or use WeakRef for entries that can be GC'd when memory is tight:
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({
max: 500,
ttl: 1000 * 60 * 5, // 5 minutes
});
app.get('/user/:id', async (req, res) => {
let user = cache.get(req.params.id);
if (!user) {
user = await db.getUser(req.params.id);
cache.set(req.params.id, user);
}
res.json(user);
});
The Closure Leak
// LEAKY: closure retains reference to large response body
function processData() {
const largeData = loadHugeDataset(); // 200 MB object
return function summarize() {
// Only needs largeData.length, but retains the entire object
return { count: largeData.length };
};
}
Fix: Extract only what you need before creating the closure:
function processData() {
const largeData = loadHugeDataset();
const count = largeData.length;
return function summarize() {
return { count };
};
}
The Timer Leak
// LEAKY: interval never cleared when connection drops
function startPolling(connection) {
setInterval(async () => {
const data = await fetchUpdates();
connection.send(data);
}, 5000);
}
Fix: Store the interval reference and clear it on cleanup:
function startPolling(connection) {
const intervalId = setInterval(async () => {
const data = await fetchUpdates();
connection.send(data);
}, 5000);
connection.on('close', () => {
clearInterval(intervalId);
});
}
Step 5: Monitor Memory in Production
Finding a leak in development is one thing — catching it in production before it causes downtime is another. Set up proper monitoring:
import { register, collectDefaultMetrics, Gauge } from 'prom-client';
collectDefaultMetrics();
const heapUsedGauge = new Gauge({
name: 'nodejs_custom_heap_used_bytes',
help: 'Current heap used in bytes',
});
setInterval(() => {
heapUsedGauge.set(process.memoryUsage().heapUsed);
}, 15000);
If you're running on AWS, make sure your instances are properly sized — underpowered instances will hit OOM faster and mask gradual leaks as sudden crashes. Our deployment guide for Node.js on EC2 covers instance sizing and monitoring setup in detail.
Alert thresholds I recommend:
- Warning at 70% of
--max-old-space-size(or container memory limit) - Critical at 85%
- Track heap growth rate — alert if heapUsed increases by more than 50 MB/hour under steady load
Step 6: Prevent Leaks with Tooling
Prevention is cheaper than debugging. Add these to your workflow:
ESLint rules: Enable no-unused-vars and consider eslint-plugin-no-only-tests to catch test-only code that might hold references.
Load testing in CI: Run a 5-minute load test in your CI pipeline and assert that memory stays within bounds:
clinic doctor --autocannon [ -c 50 -d 300 /api/health ] -- node server.js
Container memory limits: Always set memory limits on your containers. It's better to get an OOM kill and restart than to let a leaking process consume all host memory and affect other services. If you're using Docker Compose for your local development environment, set mem_limit to match your production constraints:
services:
api:
build: .
mem_limit: 512m
environment:
- NODE_OPTIONS=--max-old-space-size=384
Debugging Checklist
When you suspect a memory leak, run through this checklist:
- Confirm the leak with
process.memoryUsage()under load - Run
clinic doctorfor a quick diagnosis - Take three heap snapshots and compare deltas
- Check for the four common patterns: listeners, caches, closures, timers
- Fix the root cause — don't just increase memory limits
- Add monitoring to catch regressions
- Add a load test to CI to prevent future leaks
Memory leaks are solvable. The tooling in the Node.js ecosystem in 2026 is excellent — between clinic.js and Chrome DevTools, most leaks can be identified within an hour. The hard part isn't finding them; it's having the discipline to investigate properly instead of just restarting the process and hoping for the best.
If your team is dealing with persistent Node.js performance issues beyond what tooling can solve, Adaptels offers consulting and hands-on help for production Node.js applications.
Sources
- Node.js Diagnostics Documentation — official guide on debugging and profiling Node.js applications
- V8 Blog — Memory Management — V8 engine internals including garbage collection strategies
- clinic.js Documentation — open-source Node.js performance profiling suite by NearForm
- Chrome DevTools Memory Panel Reference — official documentation for heap snapshot analysis
- lru-cache npm package — the most widely-used LRU cache implementation for Node.js