Reading RSS vs Heap Used in Production Node.js
Most production memory investigations go wrong in the first ten minutes, when someone opens a heap snapshot for a problem that was never in the heap. This page belongs to production memory monitoring and container limits within Node.js Server-Side Memory Management, and covers reading the four numbers well enough to pick the right investigation.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
heapUsed floor rises steadily |
JavaScript objects retained per request | Capture two heap snapshots and diff them |
rss rises while heapUsed is flat |
External buffers or native allocations | Chart external and arrayBuffers |
external rises, arrayBuffers does not |
A native module holding memory | Audit driver and addon handle lifetimes |
heapTotal far above heapUsed, stable |
Fragmentation or post-spike reservation | Watch the ratio; no action if it stops widening |
rss never falls after a spike |
Pages not returned to the OS | Compare the floor across spikes, not the peak |
Root Cause: Four Numbers, Four Different Questions
process.memoryUsage() returns an object whose fields are frequently treated as interchangeable. They are not.
heapUsed is live JavaScript objects at the moment of the call. Between collections it includes garbage that has not been swept yet, which is why a single reading is nearly meaningless and the minimum over a window is what carries the signal.
heapTotal is how much heap V8 has reserved from the operating system. The difference between it and heapUsed is slack and fragmentation: pages V8 holds but is not filling. A stable gap is normal; a widening gap on a flat heapUsed is fragmentation worth investigating.
external is memory held by C++ objects bound to JavaScript ones — Buffer data, ArrayBuffer backing stores, some stream internals, and allocations made by native addons. The related arrayBuffers field is the subset attributable to ArrayBuffers specifically, and comparing the two separates your own buffers from a module’s.
rss is the resident set size: everything the kernel counts, including all of the above plus the code segment, thread stacks and allocator pools. It is the number the container limit compares against, and therefore the number that decides whether the process is killed.
The diagnostic value is in the combinations. A rising heap floor with a proportionally rising rss is ordinary object retention. A flat heap floor with a rising rss is not, and no heap snapshot will explain it.
Step-by-Step Fix
- Put all four on one chart. Command: the exporter from the parent guide, sampling every 10 s. Expected: four series with visible relative movement, not four dashboards.
- Compute the floor. Command: minimum
heapUsedper five-minute bucket. Expected: a line far smoother than the raw series, whose slope is the retention signal. - Measure the
rssminusheapTotalgap. Expected: roughly constant on a healthy service. Growth here is external, native or allocator pooling. - Split
externalfromarrayBuffers. Expected: ifarrayBuffersaccounts for the growth it is your own buffers; if not, it is a native module or the runtime. - Choose the investigation from the pair. Verification: you open a heap snapshot only in the case where the heap floor is actually rising — which is the point of the whole exercise.
Command & Code Reference
A one-line diagnostic you can paste into any service to see all four numbers with their relationships.
function memorySummary() {
const { heapUsed, heapTotal, external, arrayBuffers, rss } = process.memoryUsage();
const mb = (n) => (n / 1048576).toFixed(1).padStart(7);
return [
`heapUsed ${mb(heapUsed)} MB`,
`heapTotal ${mb(heapTotal)} MB`,
`external ${mb(external)} MB`,
`arrayBuffers ${mb(arrayBuffers)} MB`,
`rss ${mb(rss)} MB`,
// The two derived numbers that actually drive the diagnosis:
`slack ${mb(heapTotal - heapUsed)} MB`,
`non-heap ${mb(rss - heapTotal)} MB`,
].join(' · ');
}
setInterval(() => console.log(memorySummary()), 30_000).unref();
Computing the post-collection floor, which is the only heap series worth alerting on.
// A rolling minimum over a window: the floor tracks retention while the raw
// series tracks allocation rate, which is not what you want to page someone for.
function createFloorTracker(windowMs = 5 * 60_000) {
let samples = [];
return {
add(value, now) {
samples.push({ value, now });
samples = samples.filter((s) => now - s.now <= windowMs);
return Math.min(...samples.map((s) => s.value));
},
};
}
const floor = createFloorTracker();
setInterval(() => {
const now = Date.now();
const f = floor.add(process.memoryUsage().heapUsed, now);
console.log(`heap floor ${(f / 1048576).toFixed(1)} MB`);
}, 10_000).unref();
Confirming whether growth is inside or outside the heap, with the collector forced so the answer is unambiguous.
// node --expose-gc service.js
async function classifyGrowth(runWorkload) {
global.gc();
const a = process.memoryUsage();
await runWorkload();
global.gc();
const b = process.memoryUsage();
const dHeap = b.heapUsed - a.heapUsed;
const dExternal = b.external - a.external;
const dRss = b.rss - a.rss;
// Whichever delta dominates decides the next step.
console.log({
heapMb: (dHeap / 1048576).toFixed(1),
externalMb: (dExternal / 1048576).toFixed(1),
rssMb: (dRss / 1048576).toFixed(1),
verdict: dHeap > dExternal ? 'JS retention → heap snapshot'
: 'external → audit buffers and native modules',
});
}
Verification & Regression Prevention
Verify the classification before acting on it: run the workload under --expose-gc with the classifyGrowth helper and confirm which delta dominates. A five-minute measurement here routinely saves an afternoon of snapshot reading, and it produces a number you can paste into the incident channel instead of an opinion.
To prevent the wrong-investigation failure from recurring, put the derived series — heapTotal − heapUsed and rss − heapTotal — on the dashboard next to the raw ones, and label them “slack” and “non-heap”. Named derived series are what make the diagnosis obvious to whoever is on call at 3am, and they cost nothing beyond two extra panels.
FAQ
Why does RSS stay high after memory is freed?
Freeing memory inside the process does not necessarily return pages to the operating system. V8 keeps reclaimed heap pages for future allocation, and the system allocator holds freed native memory in its own pools. RSS therefore behaves like a high-water mark that decays slowly, which is normal and not a leak on its own.
Which number should an alert use?
Use two. The post-collection floor of heapUsed catches JavaScript retention, and rss catches everything else including the memory the container limit actually counts. Alerting on only one of them leaves a whole class of incident invisible.
What does a large heapTotal with a small heapUsed mean?
It means V8 has reserved more heap than it is currently using — usually after a workload spike, and often with fragmentation preventing the pages from being released. It is worth watching if the gap keeps widening, but it is not retention: the objects are already gone.
Related
- Production Memory Monitoring and Container Limits — the exporter and alerting rules these numbers feed
- Fixing OOMKilled Node.js Containers in Kubernetes — what happens when
rssreaches the limit - Typed Arrays, Buffers and External Memory — where
externalbytes come from