GC Overhead vs --max-old-space-size

5,000 iterations  ·  200 entries  ×   ·  x-axis: flag value (effective heap_size_limit in parentheses)

1  GC share of elapsed time  (%)
2  GC total time  (ms)
3  GC event count
4  Avg time per event  (ms / event)

How to read these charts

Chart What to look for
1  GC share (%) At 64 MB with a ~20 MB working set, MajorGC alone consumes ~55 % of elapsed time. The fraction drops steeply through 512 MB and plateaus near zero beyond 1–2 GB, where MinorGC dominates and MajorGC barely fires. Switch to the smaller entry size to see this effect compress; switch to the larger to see even more aggressive GC pressure at small heaps.
2  GC total time (ms) MajorGC total collapses as the heap grows (high at 64 MB, near zero at ≥1 GB). MinorGC total rises in the opposite direction: with a large heap the young generation fills up without MajorGC intervening, forcing more minor scavenges.
3  GC event count MajorGC fires dozens of times at 64 MB and approaches zero at ≥1 GB; IncrementalGC at 128+ MB tracks MajorGC 1:1. At 64 MB many major GCs are triggered by allocation failure rather than the incremental scheduler, so IncrementalGC count is lower. MinorGC count increases steadily with heap size.
4  Avg time / event (ms/event) Counter-intuitively, each individual MajorGC event takes longer as the heap grows (a few ms at 64 MB, tens of ms at multi-GB sizes). With a large heap V8 commits more address space and has more internal metadata to process per cycle. The net overhead is still lower because events are far less frequent.

Background

A 200-entry circular cache keeps the working set alive in old generation. With a small heap the working set occupies a large fraction of available space, so V8 runs Major GC (MarkCompact via incremental marking) aggressively. With a large heap the same working set is negligible — Major GC barely fires.

Modern V8 routes all Major GC through the incremental marking pipeline, so the stop-the-world MarkCompact phase shows up as MajorGC (kind=4), not ConstructRetained (kind=2). Each MajorGC is paired with a corresponding IncrementalGC event (kind=8) for the preceding marking phase.

Practical guidance

The 2× working-set rule. When the heap is smaller than roughly 2× the working set, MajorGC fires at extreme frequency (100–130 events per run), consuming 40–70 % of elapsed time. Once the heap exceeds 2–3× the working set, MajorGC event count drops to single digits and total GC overhead settles into the 30–40 % range. Set --max-old-space-size to at least 2× the old-generation working set; 3× provides a comfortable margin.

The ≈ 30–35 % MinorGC floor. Even with an arbitrarily large heap, total GC overhead does not approach zero — it plateaus around 30–35 % of elapsed time, driven entirely by MinorGC. A larger heap allows more objects to accumulate in old generation without triggering MajorGC, so new-space fills up faster and minor scavenges become more frequent. Reducing this floor requires lowering the object allocation rate, not increasing heap size.

Latency vs. throughput trade-off. Increasing heap size reduces total GC overhead (throughput benefit) but raises the duration of individual MajorGC pauses (latency cost). For latency-sensitive services, infrequent but long stop-the-world pauses at multi-GB heaps can be more harmful than frequent short pauses at a smaller heap. If single-pause latency matters, consider sharding the workload across more processes so each process’s working set stays small.

GC kinds

Kind Name What it is
1 MinorGC Scavenger on the young generation (new space). Fast; promotes survivors to old generation.
2 ConstructRetained Stop-the-world MarkCompact without incremental marking. Rare in normal runs; appears when GC is forced (e.g. global.gc() with --expose-gc).
4 MajorGC Full old-generation collection (MarkCompact) with incremental marking. This is the dominant cost under heap pressure.
8 IncrementalGC One incremental marking step. V8 slices marking work across many small pauses between JS execution turns.
16 WeakCB Processing weak references and their finalizer callbacks after a GC cycle.
32 AllExternalMemory GC triggered to account for externally allocated memory (e.g. large Buffer / ArrayBuffer backing stores) that exceeded a threshold.
64 ScheduleIdle Idle-time GC — V8 schedules a collection when the event loop has spare time, to amortise future pressure.