Voyager 1 has been running continuously since 1977. It's over 15 billion miles from Earth, still sending data back, and it does all of this on roughly 69.63 KB of memory. Meanwhile, I watched a fresh create-react-app project eat 300 MB of RAM last Tuesday before it rendered a single div.
Something has gone very wrong with how we think about resources.
I'm not saying we should all write spacecraft firmware. But when your Express server is consuming 1.2 GB in production and you can't figure out why, that's a real problem with real costs. Let's debug it.
The symptom: memory keeps climbing
You've probably seen this pattern. Your Node.js app starts fine, memory usage looks normal, then over hours or days it creeps up. Eventually it hits the container limit, gets OOM-killed, restarts, and the cycle begins again.
The frustrating part? Everything looks fine locally. Your tests pass. Your code reviews didn't catch anything. But production tells a different story.
# Check current Node.js heap usage from inside your app
node -e "console.log(process.memoryUsage())"
# {
# rss: 30441472, // total memory allocated
# heapTotal: 6066176, // V8 heap allocated
# heapUsed: 3874520, // V8 heap actually used
# external: 1024126, // C++ objects bound to JS
# arrayBuffers: 10507 // ArrayBuffer and SharedArrayBuffer memory
# }Those numbers look reasonable on startup. The problem is what happens next.
Root cause #1: accidental closures holding references
This is the most common memory leak I've debugged in Node.js apps, and it's sneaky because the code looks perfectly reasonable.
const express = require('express');
const app = express();
// This cache looks harmless
const responseCache = {};
app.get('/api/users/:id', async (req, res) => {
const userId = req.params.id;
if (!responseCache[userId]) {
const userData = await fetchUser(userId);
// Every unique user ID adds an entry that NEVER gets cleaned up
responseCache[userId] = {
data: userData,
timestamp: Date.now(),
// This closure captures the entire `req` object — huge
headers: req.headers
};
}
res.json(responseCache[userId].data);
});See the problem? That responseCache object grows forever. Every unique user ID adds an entry, and storing req.headers means you're holding a reference to request objects that should have been garbage collected. In a busy API, this is death by a thousand paper cuts.
Voyager 1's engineers had to think about every single byte. We don't need to go that far, but we should at least think about whether our data structures have an upper bound.
The fix: bounded caches and weak references
// Option 1: Simple LRU with a max size
// npm install lru-cache
const { LRUCache } = require('lru-cache');
const responseCache = new LRUCache({
max: 500, // never more than 500 entries
ttl: 1000 * 60 * 5, // entries expire after 5 minutes
maxSize: 5000,
sizeCalculation: (value) => {
return JSON.stringify(value).length; // rough byte estimate
},
});
// Option 2: If you just need short-lived deduplication, use a WeakRef
const pending = new Map();
async function fetchUserDeduplicated(userId) {
const existing = pending.get(userId)?.deref();
if (existing) return existing;
const promise = fetchUser(userId);
pending.set(userId, new WeakRef(promise));
// Clean up the map entry after resolution
promise.finally(() => pending.delete(userId));
return promise;
}Root cause #2: event listeners that never get removed
This one bit me hard in a WebSocket server. Every time a client connected, we attached listeners. Every time they disconnected... some of those listeners stuck around.
// BAD: listener accumulation
function handleConnection(socket) {
const onData = (chunk) => processChunk(chunk, socket);
const onError = (err) => logError(err, socket);
// These listeners reference `socket` via closure
process.on('SIGTERM', () => socket.destroy());
externalEmitter.on('config-update', () => {
socket.write(JSON.stringify(getConfig()));
});
socket.on('data', onData);
socket.on('error', onError);
socket.on('close', () => {
socket.removeListener('data', onData);
socket.removeListener('error', onError);
// Forgot to remove the process and externalEmitter listeners!
// Those closures still reference `socket`, preventing GC
});
}Node.js will even warn you about this — that's what the MaxListenersExceededWarning means. Don't just increase the limit. Fix the leak.
// GOOD: use AbortController to clean up all listeners at once
function handleConnection(socket) {
const ac = new AbortController();
process.on('SIGTERM', () => socket.destroy(), { signal: ac.signal });
externalEmitter.on('config-update', () => {
socket.write(JSON.stringify(getConfig()));
}, { signal: ac.signal }); // EventEmitter supports this in Node 20+
socket.on('close', () => {
ac.abort(); // removes ALL listeners registered with this signal
});
}The AbortController pattern is cleaner than tracking individual listener references. One call to abort() tears everything down.
How to actually find these leaks
Reading code and guessing is slow. Here's the process I actually use.
Step 1: Take heap snapshots
// Add this endpoint to your app (behind auth, obviously)
const v8 = require('v8');
const fs = require('fs');
app.get('/debug/heapdump', (req, res) => {
const filename = `/tmp/heap-${Date.now()}.heapsnapshot`;
const snapshotStream = v8.writeHeapSnapshot(filename);
res.json({ file: snapshotStream });
});Take a snapshot after startup, then another after the memory has climbed. Load both into Chrome DevTools (Memory tab → Load) and use the "Comparison" view. Sort by "Size Delta" to find what's growing.
Step 2: Track allocations over time
# Start your app with the inspect flag
node --inspect server.js
# Open chrome://inspect in Chrome
# Go to Memory tab → "Allocation instrumentation on timeline"
# Let it run during normal traffic, then stop and analyzeStep 3: Monitor in production
Add basic memory tracking to your health check endpoint:
app.get('/health', (req, res) => {
const mem = process.memoryUsage();
res.json({
status: 'ok',
memory: {
heapUsedMB: Math.round(mem.heapUsed / 1024 / 1024),
heapTotalMB: Math.round(mem.heapTotal / 1024 / 1024),
rssMB: Math.round(mem.rss / 1024 / 1024),
},
uptime: process.uptime(),
});
});Graph this over time. A healthy app's memory should stabilize. If it's a straight line going up, you have a leak.
Prevention: the stuff that actually works
After debugging enough of these, here's what I've landed on:
- Set
--max-old-space-sizeexplicitly. Don't let Node.js decide. If your container has 512 MB, set it to 400 MB. This makes leaks crash earlier and louder instead of silently degrading. - Every
Map,Set, or plain object used as a cache needs a size limit. No exceptions. If it can grow, it will grow. - Use
WeakMapandWeakRefwhen you're associating metadata with objects. When the object gets GC'd, the metadata goes with it. - Audit your
on()calls. EveryaddEventListeneroremitter.on()should have a corresponding removal path. TheAbortControllerpattern makes this manageable. - Run your app under load locally with
clinic.js. Specificallyclinic doctorandclinic heapwill surface issues before production does.
# Install clinic.js globally
npm install -g clinic
# Generate a flamegraph-style heap report
clinic heapprofiler -- node server.js
# Then hit it with autocannon or similar
# npx autocannon -d 60 http://localhost:3000/api/users/1The Voyager lesson
NASA's engineers built Voyager's Computer Command System with 69.63 KB of memory because that's what they had. Constraints forced them to be intentional about every allocation, every buffer, every byte. They didn't have the luxury of "just add more RAM."
We do have that luxury, and most of the time that's fine. But when your cloud bill is climbing, your pods are restarting at 3 AM, and your users are getting 502s — that's when it helps to think a little more like a 1977 spacecraft engineer.
Not every byte matters. But every unbounded allocation does.
