A Node.js memory leak shows up as a heap floor that keeps rising across garbage collection cycles, where a healthy process draws a sawtooth that always returns to the same baseline. To confirm one, trend process.memoryUsage() in your logs, capture two heap snapshots under load, and diff them in Chrome DevTools to read the retainer path. Most production leaks trace back to unbounded caches, per-request event listeners, captured closures, or forgotten timers. Here is the method we apply when a service starts drifting.
How do we tell a real leak from normal GC behavior?
A healthy Node.js process shows a sawtooth: heapUsed climbs between garbage collections, then drops back to roughly the same floor. A leak shows a floor that rises across cycles: each collection frees less than the previous one. Judge the minimum after full GC over hours, not instant values, before concluding anything.
Then read the right counters. process.memoryUsage() reports rss, heapTotal, heapUsed, external and arrayBuffers. A rising heapUsed floor means JavaScript objects are being retained. Rising RSS with a steady heap points outside V8: Buffers, native addons, or unreleased ArrayBuffer memory. Log all five fields once a minute and graph them; the shape of the curve narrows the search before you ever open a profiler.
| Symptom | Likely cause | First check |
|---|---|---|
RSS rises, heapUsed steady | Native or external memory (Buffers, addons) | external and arrayBuffers in process.memoryUsage() |
heapUsed floor rises across GC cycles | JS objects retained (cache, closures, listeners) | Diff two heap snapshots in DevTools |
| OOM after N requests | Per-request accumulation | Correlate memory with the request counter, inspect per-request listeners |
| Lambda OOM after warm reuse | Module-scope accumulation | Audit module-level state, plot memory per invocation |
Which leaks bite most often?
In our experience the ranking is stable: unbounded caches and Maps first, event listeners added per request and never removed second, then closures capturing large objects in long-lived scopes, timers that are never cleared, and module-level accumulation in serverless environments. Check them in that order before reaching for a profiler.
The first two share a signature: memory grows with traffic, not with time. A Map used as a cache has no eviction policy, so every distinct key becomes a permanent resident. Listeners are subtler: attaching a handler to a shared emitter inside a request handler keeps each request's closure alive, and Node.js prints a MaxListenersExceededWarning long before the crash. Treat that warning as a leak report, not as noise.
// Leak: one entry per distinct key, kept forever
const cache = new Map();
async function getUser(id) {
if (!cache.has(id)) cache.set(id, await db.users.findById(id));
return cache.get(id);
}
// Fix: replace the Map with a bounded cache
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({ max: 5000, ttl: 60_000 });
Closures and timers follow the same logic: a setInterval that is never cleared retains everything its callback references, indefinitely.
How do we take a heap snapshot in production safely?
Two supported paths: start the process with --heapsnapshot-signal and send it a signal, or call v8.writeHeapSnapshot() from a guarded admin route. Both block the process while writing, and the Node.js docs warn the operation can need memory around twice the current heap, so snapshot an instance taken out of rotation.
# Option 1: opt in at startup, trigger with a signal
node --heapsnapshot-signal=SIGUSR2 server.js
kill -USR2 <pid> # writes Heap.*.heapsnapshot next to the process
// Option 2: guarded admin route, never public
import v8 from 'node:v8';
app.post('/admin/heap-snapshot', requireAdmin, (req, res) => {
const file = `/tmp/heap-${process.pid}-${Date.now()}.heapsnapshot`;
v8.writeHeapSnapshot(file); // blocks the event loop while writing
res.json({ file });
});
Take the first snapshot once the service is warm, hold the load steady, and take the second after memory has visibly grown, typically thirty minutes or a few thousand requests later. Copy both files off the host: they contain your data, so treat them as sensitive.
Memory climbing on a Node.js service you run? Describe your system: a one-page diagnosis within 48 hours.
Get my diagnosis →How do we read two snapshots in Chrome DevTools?
Load both files in the DevTools Memory panel, select the newer one, and switch the summary view to "Objects allocated between Snapshot 1 and Snapshot 2". Sort by retained size, expand the largest constructor, and read the Retainers pane: it names the exact reference chain, often a Map or a listener array, keeping those objects alive.
Follow that chain upward until you recognize a file of your own, then apply the loop we use on every engagement: measure, snapshot at T and T+N under load, diff, fix the retainer, deploy, re-measure. The floor should return to flat within one release. If allocations are too noisy to read, the clinic.js heap profiler is a reasonable complement. What does not help is raising --max-old-space-size: it moves the OOM further away, lengthens GC pauses, and hides the trend you need to see. Chrome's memory problems guide documents the panel in depth, and this measure-and-diff loop is the core of our performance optimization engagements.
Why does a Lambda function run out of memory after N invocations?
Because execution environments are reused. The platform freezes your process between invocations and thaws it for the next one, so module-level state survives warm starts. A small per-invocation accumulation grows until the environment dies with an out-of-memory error after N invocations, then a cold start resets it and the cycle repeats silently.
The telltale signs: a memory metric that climbs across warm invocations and resets after each cold start, and OOM errors arriving in batches rather than at random. Keep expensive clients (database connections, SDK instances) at module scope on purpose, that is exactly what warm reuse is for, but never store anything keyed by request there. Per-request data belongs inside the handler, or in a bounded cache with a TTL if it must be shared.
How do we stop leaks from coming back?
Bound everything that grows. Caches get a maximum size and a TTL (the lru-cache package is the standard answer), object-keyed lookups use WeakMap so entries die with their keys, and every listener registration is paired with a removal, ideally driven by an AbortSignal. A regression should be a graph, not an outage.
In practice: reserve WeakRef for cases where ownership truly lives elsewhere, pair every emitter.on() with an emitter.off() on the request's close event, clear every timer in a shutdown or cancellation path, and keep the process.memoryUsage() trend on a dashboard with an alert on the post-GC floor. That monitoring habit is a standard part of ongoing Node.js maintenance: a leak caught as a trend costs an afternoon, a leak caught as an outage costs a weekend.
Production memory leak checklist
- Trend all five
process.memoryUsage()fields; judge the post-GC floor, not the spikes. - Classify first: heap vs external, time-correlated vs traffic-correlated.
- Snapshot at T and T+N under steady load, on an instance out of rotation.
- Diff in DevTools, sort by retained size, follow the Retainers pane to your code.
- Fix, deploy, re-measure; never ship
--max-old-space-sizeas the fix. - Bound caches, pair every
on()withoff(), clear timers, audit module scope in serverless.