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”.
Step-by-Step Fix
- Record the events. DevTools path: Performance → tick Memory → record. Command (Node): a
PerformanceObserveronentryTypes: ['gc']. Expected: a stream of events with durations and kinds. - Classify. Expected: sub-millisecond and frequent → scavenge; multi-millisecond and seconds apart → major.
- Read the right signal. Expected: rising scavenge duration means more objects surviving; rising major frequency at constant traffic means the live set is growing.
- Apply the matching fix. Scavenge pressure → shorten lifetimes and reuse buffers. Major pressure → find the retention, as in interpreting heap snapshots.
- 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;
}
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.
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.
Related
- How Mark-and-Sweep Garbage Collection Works — the marking algorithm behind the major collection
- How to Tune V8 Garbage Collection Thresholds for SPAs — sizing the young generation
- Understanding the V8 Heap Layout and Memory Segments — the spaces each collector owns