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.

Four combinations, four different investigations A two by two matrix. Heap floor flat with flat resident set is healthy. Heap floor rising with rising resident set is JavaScript object retention. Heap floor flat with rising resident set is external or native memory. Heap floor rising with flat resident set indicates a measurement problem rather than a real state. Read the pair, not either number alone heap floor flat · rss flat Healthy. Sawtooth between collections is allocation rate, not retention. no action heap floor rising · rss rising JavaScript object retention — the classic per-request leak. next: two heap snapshots, diffed heap floor flat · rss rising External or native memory. A snapshot will show nothing. next: chart external and arrayBuffers heap floor rising · rss flat Not a real state for long — usually a sampling artefact. next: check the floor calculation

Step-by-Step Fix

  1. 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.
  2. Compute the floor. Command: minimum heapUsed per five-minute bucket. Expected: a line far smoother than the raw series, whose slope is the retention signal.
  3. Measure the rss minus heapTotal gap. Expected: roughly constant on a healthy service. Growth here is external, native or allocator pooling.
  4. Split external from arrayBuffers. Expected: if arrayBuffers accounts for the growth it is your own buffers; if not, it is a native module or the runtime.
  5. 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',
  });
}
A real incident: flat heap, climbing RSS Over four hours the heap floor stays between eighty and ninety megabytes while resident set size climbs from two hundred and forty to seven hundred and sixty. The widening gap between the two lines is labelled as external buffers, and a heap snapshot taken at the end shows nothing unusual. Four hours of an incident where the heap was never the problem 0 400 MB 800 MB elapsed (0 → 4 h) heapUsed floor: flat at ~85 MB rss: 240 → 760 MB the gap is external buffers Three engineers spent two hours in heap snapshots before anyone charted the second line.

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.

The six panels worth having Four raw panels show heap floor, heap total, external and resident set size. Two derived panels show slack, the gap between heap total and heap used, and non-heap, the gap between resident set size and heap total. The derived panels are what make the diagnosis immediate. Four raw series, two derived — the derived ones do the diagnosing heapUsed floor JS retention heapTotal reserved by V8 external buffers + native rss what the limit counts slack = heapTotal − heapUsed widening on a flat heap → fragmentation non-heap = rss − heapTotal widening → external or native, not the heap

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.