Scavenger vs Major GC: When Each Runs

The Performance panel labels both events “GC”, which hides the most useful distinction in V8’s collector: one of them is cheap and constant, the other is expensive and grows with your leak. This page belongs to how mark-and-sweep garbage collection works inside JavaScript Memory Fundamentals & Runtime Mechanics.

Symptom Root Cause Immediate Action
Hundreds of sub-millisecond GC events per minute Ordinary scavenging of short-lived objects Usually fine — check the survivor rate
Major collections accelerating at constant traffic Live set growing; each collection frees less Treat as a leak signal and diff two snapshots
Long GC blocks in the flame chart Major collections on a large live set Reduce retention, not allocation
Scavenges taking milliseconds each A high proportion of objects surviving Shorten lifetimes so objects die young
Time in GC above 5% of wall clock Collector cannot keep up Alert on the ratio, not the count

Root Cause: Two Collectors With Opposite Cost Models

V8’s young generation is collected by the scavenger, a copying collector. It walks the live objects in the nursery, copies them to the other semi-space, and declares the whole source region free. Its cost is proportional to the number of survivors, not to how much was allocated — which is why allocating a million short-lived objects is nearly free while allocating a hundred thousand that survive is not.

The old generation is collected by mark-compact, a tracing collector. It marks everything reachable from the roots, sweeps the unmarked, and compacts fragmented pages. Its cost is proportional to the live set — the amount of memory still reachable — which is exactly the quantity a leak increases.

That opposition explains almost every GC pattern you will see. A high allocation rate produces frequent scavenges with flat cost. A leak produces major collections that get slower and more frequent, because each one frees less and V8 must run them sooner to keep up. Frequency at constant traffic is therefore a leak indicator in a way that raw heap size is not.

Objects move between the two regimes by promotion: survive two scavenges and you are copied into old space, where only a major collection can reclaim you. Keeping objects short-lived is the single most effective way to keep work in the cheap collector — which is why the practical advice reduces to “let things die young”, not “allocate less”.

Ten seconds of collector activity Scavenges appear as many narrow marks spread evenly across the ten seconds, each under a millisecond. Two major collections appear as wide blocks of forty and fifty-five milliseconds. Together the scavenges account for less total time than either single major collection. Same ten seconds, drawn to scale Scavenges · 42 events, < 1 ms each Major collections · 2 events, 40 ms and 55 ms 40 ms 55 ms Total scavenge time: ~18 ms across 42 events. Total major time: 95 ms across 2. Counting events tells you nothing; the width of the blocks is where the latency lives.

Step-by-Step Fix

  1. Record the events. DevTools path: Performance → tick Memory → record. Command (Node): a PerformanceObserver on entryTypes: ['gc']. Expected: a stream of events with durations and kinds.
  2. Classify. Expected: sub-millisecond and frequent → scavenge; multi-millisecond and seconds apart → major.
  3. Read the right signal. Expected: rising scavenge duration means more objects surviving; rising major frequency at constant traffic means the live set is growing.
  4. Apply the matching fix. Scavenge pressure → shorten lifetimes and reuse buffers. Major pressure → find the retention, as in interpreting heap snapshots.
  5. Verify with the ratio. Expected: time spent in major collections as a fraction of wall clock falls below a couple of per cent.

Command & Code Reference

Separating the two kinds in Node, which the raw event stream mixes together.

const { PerformanceObserver, constants } = require('node:perf_hooks');

const stats = { minor: { n: 0, ms: 0 }, major: { n: 0, ms: 0 } };

new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    // kind distinguishes the collectors; duration is in milliseconds.
    const bucket = e.detail?.kind === constants.NODE_PERFORMANCE_GC_MAJOR
      ? stats.major : stats.minor;
    bucket.n++;
    bucket.ms += e.duration;
  }
}).observe({ entryTypes: ['gc'] });

setInterval(() => {
  const pct = (ms) => ((ms / 60_000) * 100).toFixed(2) + '%';
  console.log(
    `minor ${stats.minor.n} events / ${pct(stats.minor.ms)} · ` +
    `major ${stats.major.n} events / ${pct(stats.major.ms)}`
  );
  stats.minor = { n: 0, ms: 0 };
  stats.major = { n: 0, ms: 0 };
}, 60_000).unref();

Watching promotion, which is what moves work from the cheap collector to the expensive one.

// node --trace-gc app.js prints one line per collection:
//   [pid:0x...]  1234 ms: Scavenge 41.2 (58.0) -> 12.4 (58.0) MB, 0.8 / 0.0 ms
//   [pid:0x...]  9876 ms: Mark-Compact 402.1 (440.0) -> 210.6 (440.0) MB, 48 / 0 ms
//
// The numbers are: heap before (total) -> heap after (total), pause / external.
// A Scavenge whose "after" figure keeps rising is promoting heavily, which is
// what eventually forces the Mark-Compact events.

Reducing promotion by letting objects die in the nursery.

// Promotes: every parsed row is held until the whole batch is built, so
// survivors accumulate across several scavenges and get copied to old space.
function parseAllThenUse(lines) {
  const rows = lines.map(parseRow);      // all alive at once
  return summarise(rows);
}

// Does not promote: each row is consumed and dropped within one scavenge
// window, so the collector's cheap path handles the whole workload.
function parseAndFold(lines) {
  let acc = emptySummary();
  for (const line of lines) {
    const row = parseRow(line);          // dies before the next scavenge
    acc = fold(acc, row);
  }
  return acc;
}
Major collection frequency at constant traffic At constant request volume, a healthy service runs a steady two major collections per minute for the whole hour. A leaking service starts at two and accelerates to eleven per minute as the live set grows and each collection frees less. Traffic is flat in both runs; only the live set differs 0 6/min 12/min elapsed (0 → 60 min) healthy: steady at 2/min growing live set: 2 → 11/min Frequency at constant traffic is a leak signal that arrives well before the heap looks alarming.

Verification & Regression Prevention

Verify with the time-in-GC ratio rather than with counts. A fix that halves the number of major collections but doubles their duration has changed nothing; the fraction of wall-clock time spent collecting is the number that maps to user-visible latency.

For prevention, export that ratio as a metric — the exporter in production memory monitoring includes it — and alert above a few per cent. It is dimensionless, so it survives a change in instance size or heap ceiling, and it moves earlier than any absolute memory threshold. Pair it with a scavenge-duration series: rising scavenge duration at constant allocation means more objects are surviving, which is promotion pressure forming before it becomes a major-collection problem.

Four collector signals and what to do about each Many short scavenges means a high allocation rate and is usually fine. Long scavenges mean high survival, so shorten lifetimes. Frequent major collections mean a growing live set, so look for retention. Long major collections mean a large live set, so reduce the working set. Read the two dimensions separately: how often, and how long Signal Means Action many short scavenges high allocation rate usually none needed scavenges getting longer more objects surviving shorten object lifetimes majors getting more frequent live set growing — a leak diff two heap snapshots majors getting longer large live set reduce the working set Only the bottom two rows are memory bugs; the top two are throughput characteristics.

FAQ

Is a frequent scavenge a problem?

Usually not. A scavenge costs well under a millisecond and its cost scales with the number of survivors, not with how much was allocated, so a high allocation rate of short-lived objects is close to free. It becomes a problem only when a large share of those objects survive, which turns every scavenge into a copying exercise.

Why do major collections get more frequent as a leak grows?

Because their cost scales with the live set. A growing live set means each collection takes longer and frees less, so V8 has to run them more often to keep up with allocation. The accelerating frequency at constant traffic is one of the earliest reliable leak signals.

Can I make V8 prefer one over the other?

Only indirectly. A larger young generation means fewer scavenges, each slightly longer, and fewer objects promoted — which reduces major collections too. Beyond that, the collector’s scheduling is not something to control; the workload’s allocation and retention patterns are.