The graph looked harmless at first.
09:00 → 420 MB
12:00 → 610 MB
15:00 → 790 MB
18:00 → 1.1 GB
Traffic dropped.
Memory didn't.
Eventually:
FATAL ERROR:
JavaScript heap out of memory
When a large Node.js monolith starts behaving like this, the tempting fix is:
node --max-old-space-size=8192 app.js
Sometimes that buys time.
It doesn't tell us why memory keeps growing.
A useful Node memory leak investigation starts by identifying which kind of memory is growing, then finding what continues retaining it.
First: Confirm It's Actually a Leak
Not every growing memory graph means leaked JavaScript objects.
Start with:
const memory = process.memoryUsage();
console.table({
rss: memory.rss,
heapTotal: memory.heapTotal,
heapUsed: memory.heapUsed,
external: memory.external,
arrayBuffers: memory.arrayBuffers
});
These numbers describe different things.
heapUsed
→ JavaScript objects currently using V8 heap
heapTotal
→ Heap currently allocated by V8
external
→ Memory associated with native/C++ objects
arrayBuffers
→ ArrayBuffer/SharedArrayBuffer memory,
including Node Buffers
rss
→ Overall resident process memory
That distinction is important.
If:
heapUsed ↑ continuously
we may have retained JavaScript objects.
If:
RSS ↑
heapUsed ≈ stable
we should also investigate Buffers, native modules, allocator behavior, or other memory outside the normal V8 heap.
Don't diagnose from one metric.
Look at the Shape of Memory
Healthy applications allocate memory constantly.
Garbage collection should recover much of it.
Conceptually:
Memory
│ /\ /\ /\
│ / \ / \ / \
│____/ \__/ \__/ \__
└─────────────────────────────
The interesting pattern is:
Memory
│ /\
│ __/ \
│ __/
│ __/
│___/
└─────────────────────────────
The baseline keeps climbing after garbage collection.
That's when I start asking:
What is still referencing objects that should already be dead?
In a Monolith, Start With Long-Lived Containers
A large Node monolith usually has plenty of objects designed to live for the entire process lifetime.
That makes them excellent leak candidates.
Look at:
Global Maps
Caches
Module-level arrays
Event emitters
Queues
Session stores
Timers
Request registries
WebSocket connections
Consider this innocent-looking cache:
const userCache = new Map();
async function getUser(id) {
if (!userCache.has(id)) {
userCache.set(id, await loadUser(id));
}
return userCache.get(id);
}
What's the eviction policy?
There isn't one.
If new IDs continue arriving:
10,000 users
↓
50,000 users
↓
500,000 users
↓
Map keeps everything
Technically the objects are still reachable.
So garbage collection is behaving correctly.
The application is the thing leaking.
Watch Event Listeners
Another common source of retention is listeners that are added repeatedly but never removed.
function handleRequest(req) {
eventBus.on("user-updated", () => {
updateSomething(req.user);
});
}
Now imagine that happens for every request.
The listener retains its closure.
The closure may retain:
Request
User
Metadata
Other objects
Instead, lifecycle-sensitive listeners need lifecycle-sensitive cleanup.
function handler(data) {
// process event
}
eventBus.on("user-updated", handler);
// Later
eventBus.off("user-updated", handler);
The real question isn't just:
“How many listeners exist?”
It's:
“Why is this listener still alive?”
Timers Can Quietly Retain Large Object Graphs
Consider:
function startJob(customer) {
setInterval(() => {
refreshCustomer(customer);
}, 60_000);
}
That callback retains customer.
If jobs are repeatedly created without clearing old intervals:
Timer
↓
Closure
↓
Customer
↓
Associated objects
remain reachable.
Always know who owns a timer and when it ends.
const timer = setInterval(runJob, 60_000);
// lifecycle ends
clearInterval(timer);
This becomes particularly important in monoliths because dozens of unrelated modules may create their own background jobs.
Take Heap Snapshots — Carefully
Guessing eventually stops being useful.
A better investigation is:
Snapshot A
↓
Generate representative traffic
↓
Wait / reproduce growth
↓
Snapshot B
↓
Compare
We're looking for objects whose retained population or retained size keeps increasing.
Typical suspects might include:
Array
Map
Object
String
Closure
Buffer wrappers
Application-specific classes
Then inspect the retaining path.
For example:
Global Cache
↓
Map
↓
UserSession
↓
Orders[]
↓
Large response objects
Now we have something actionable.
Heap snapshots can temporarily consume significant CPU and memory, so capturing them blindly on a heavily loaded production instance is risky. Use a safe diagnostic strategy appropriate to your infrastructure.
For long-lived Node applications, this kind of monitoring and troubleshooting belongs alongside ordinary Node.js application development rather than being treated only as emergency work after an out-of-memory crash.
Don't Ignore Buffers
Suppose:
heapUsed = relatively stable
RSS = climbing
The JavaScript heap may not be the primary problem.
Node applications handling:
File uploads
Image processing
Streams
Compression
Large API payloads
Sockets
Binary protocols
often work heavily with Buffer.
That's why I monitor:
const {
rss,
heapUsed,
external,
arrayBuffers
} = process.memoryUsage();
If external or arrayBuffers grows alongside RSS, the investigation changes direction.
This is one reason simply increasing the V8 heap limit can completely miss the actual problem.
Reproduce One Workload at a Time
A monolith might handle:
REST API
Background jobs
Uploads
Reports
WebSockets
Emails
Scheduled tasks
Integrations
If everything runs simultaneously, identifying the leak becomes difficult.
Isolate workloads.
Baseline
↓
Run API traffic
↓
Measure
Baseline
↓
Run report generation
↓
Measure
Baseline
↓
Run background jobs
↓
Measure
You might discover:
Normal API traffic
→ stable
WebSocket traffic
→ stable
Report generation
→ memory never returns
Now the search space is dramatically smaller.
Add Memory Observability Before the Next Incident
At minimum, track:
RSS
Heap used
Heap total
External memory
ArrayBuffer memory
Process restarts
OOM events
Request volume
Job volume
Then correlate memory growth with application behavior.
Memory spike
+
Report jobs increased
+
Large Buffers increased
is much more useful than:
Server uses 1.8 GB.
Production performance should be treated as part of ongoing web application maintenance and optimization, especially when a monolith has accumulated years of APIs, integrations, jobs, and shared application state.
The Debugging Order I Prefer
When investigating a Node memory leak, I use roughly this sequence:
1. Confirm sustained growth
↓
2. Compare heapUsed vs RSS
↓
3. Check external / Buffer memory
↓
4. Correlate growth with workload
↓
5. Reproduce the suspicious path
↓
6. Capture safe heap snapshots
↓
7. Compare retained objects
↓
8. Follow retaining paths
↓
9. Fix ownership / cleanup
↓
10. Repeat the workload
Then ask one final question:
Does memory return to a stable baseline?
If not, keep investigating.
Common Places I'd Check First
For a production Node monolith:
□ Unbounded Map/Set caches
□ Global arrays
□ Event listeners
□ setInterval/setTimeout
□ Request objects retained by closures
□ WebSocket connection state
□ Unfinished promises
□ Large Buffers
□ File-processing pipelines
□ In-memory sessions
□ Background queues
□ ORM/database result retention
□ Application metrics with unbounded labels
Notice that most leaks aren't mysterious V8 bugs.
They're usually object ownership problems.
Something was created.
Something should have released it.
Something didn't.
Final Takeaway
The worst way to debug a Node memory problem is:
Memory high
↓
Increase heap
↓
Restart server
↓
Wait
↓
Memory high again
A better approach is:
MEASURE
↓
CLASSIFY
↓
REPRODUCE
↓
SNAPSHOT
↓
FIND RETAINER
↓
FIX
↓
MEASURE AGAIN
The key lesson is that a Node memory leak isn't simply:
“Node is using too much RAM.”
The useful question is:
“Which memory is growing, and what is keeping it alive?”
Once you can answer that, debugging stops being guesswork.
And in a large monolith, reducing the search space is often half the fix.

Top comments (0)