Spotting Garbage Collection Pauses in a Performance Trace

Scrolling or typing stutters, the flame chart shows long tasks, and you suspect garbage collection but cannot see how much it actually costs — this guide, part of Performance Panel Flame Graph Analysis in the Browser DevTools & Performance Profiling Workflows section, shows where GC appears in a trace, how to total it, and how to trace it back to the allocating code.

Symptom Root Cause Immediate Action Measurable Impact
Frequent short Minor GC slices inside animation frames Young generation fills quickly from per-frame allocations Reduce per-frame object creation in the hot handler Minor GC count per second drops by 50–90%
A single Major GC of 50+ ms during an interaction Old generation reached its limit; a full mark-compact ran on the main thread Find what promoted so much data; check heap line before the pause Removes the long pause or moves it outside the interaction
JS heap line is a steep sawtooth High allocation rate with fast reclamation (churn) Record an allocation sampling profile for the same flow Shallower sawtooth, fewer GC events
JS heap line troughs rise over time Retained growth: each GC reclaims less Switch to heap snapshots and diff Confirms a leak rather than a GC tuning issue
GC time is small but frames still drop Jank is script or layout, not GC Stop optimising allocation; inspect the long task’s call tree Saves effort spent on the wrong cause

Root Cause: What GC Looks Like on the Main Thread

V8 collects garbage in two generations, as described in scavenger vs major GC. New objects are allocated in a small young generation; when it fills, a scavenge copies survivors out and discards everything else. This shows up in the Performance panel’s Main track as short Minor GC slices, usually well under 5 ms each. Much of the work is done in parallel on helper threads, so the main-thread slice is the part your frame budget actually pays.

Objects that survive a couple of scavenges are promoted to the old generation. When old-space usage reaches a limit that V8 computes dynamically, it starts a mark-and-sweep cycle. Modern V8 does most marking concurrently in the background and incrementally in small steps, but the finalisation pause and some sweeping and compaction still happen on the main thread, reported as Major GC. Normally that is a few milliseconds; when the heap is large, fragmented, or growing fast, it can stretch to tens of milliseconds — longer than a whole 16.7 ms frame.

Neither kind of pause is triggered by the code running at that moment. GC runs when an allocation cannot be satisfied, so the slice appears nested inside whatever function happened to allocate last. That is why a GC event in the flame chart often sits under an innocent-looking function: it is the allocation rate of the whole task — often of the whole page — that set it off. To fix GC pauses you reduce the allocation rate or the amount promoted, and you find the allocators with the allocation sampling profiler, not by staring at the function that contains the GC slice.

The Memory checkbox adds a JS heap line under the flame chart. Its shape tells you which problem you have: a steep sawtooth with a flat floor means churn, while a sawtooth whose troughs climb means retention.

Minor and Major GC in the main track Three frames of 16.7 milliseconds are marked. A scroll handler task in frame one contains three short Minor GC slices of about 1 to 2 milliseconds. A later task in frames two and three contains a 38 millisecond Major GC slice that crosses a frame boundary, causing a dropped frame. Below, a JS heap line shows small sawtooth drops at each minor GC and a large drop at the major GC. Main 16.7 ms 33.3 ms Task: scroll handler renderVisibleRows 3 × Minor GC (1–2 ms) Task: click → applyFilter Major GC — 38 ms, heap 412 → 236 MB crosses a frame boundary → dropped frame JS heap big drop after Major GC

Step-by-Step Fix

  1. Record with memory enabled. Open DevTools → Performance, tick Memory (and Screenshots if you want visual context), click Record, perform the janky interaction, then Stop. Keep the recording under 10 seconds. Verification: the JS heap line appears under the flame chart.
  2. Find the GC slices. Zoom into the janky region of the Main track and look for slices named Minor GC and Major GC. Click one: the Summary tab shows its duration and the heap size before and after. Verification: you can state the longest GC pause in ms and how much it collected in MB.
  3. Total GC time for the interaction. Select the interaction range in the overview, open the Bottom-Up tab, and type GC into the filter box. Verification: you have total GC self time for the range; compare it to total scripting time. Under ~10% usually means GC is not your main problem.
  4. Classify the pattern from the heap line. Many Minor GCs with a flat floor means high allocation churn in the handler. One long Major GC after a rising heap means a large promotion — often a big data structure built in one go — or retained growth. Verification: you have labelled the problem as “churn”, “promotion spike” or “retention”.
  5. Find the allocators. For churn or promotion spikes, repeat the same interaction with Memory → Allocation sampling and sort Heavy (Bottom Up) by Self Size. For retention, move to heap snapshots. Verification: you have one or two functions responsible for most allocated bytes during the interaction.
  6. Reduce allocation and re-record. Reuse arrays, avoid spreading objects in hot loops, parse or transform data in chunks, and move large one-off builds out of interaction handlers. Re-record the same interaction. Verification: Minor GC count and the longest Major GC both fall, and the interaction no longer drops frames.
Reading the heap line shape Three small line sketches. Churn shows a steep sawtooth with a flat floor and points to allocation sampling. Promotion spike shows a steep climb followed by one large drop and points to allocation sampling around the interaction. Retention shows a sawtooth whose troughs rise over time and points to heap snapshot comparison. Churn flat floor, many Minor GCs → allocation sampling Promotion spike steep climb, one Major GC → sample the interaction Retention troughs rise every cycle → heap snapshot diff

Command and Code Reference

Use case: total GC time from a saved trace. Export the recording (Performance → Save profile) and sum GC events by type, which is handy for comparing before/after builds.

// gc-summary.mjs — node gc-summary.mjs Trace-20260918.json
import { readFileSync } from 'node:fs';

const raw = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const events = Array.isArray(raw) ? raw : raw.traceEvents;

// Main-thread GC slices are complete ('X') events; names vary slightly by version
const isGc = (e) => e.ph === 'X' && /^(MinorGC|MajorGC|V8\.GC_)/.test(e.name);
const totals = {};
for (const e of events) {
  if (!isGc(e)) continue;
  const key = e.name.startsWith('Minor') || e.name.includes('SCAVENGE') ? 'minor' : 'major';
  totals[key] ??= { count: 0, ms: 0, maxMs: 0 };
  const ms = e.dur / 1000; // trace durations are microseconds
  totals[key].count++;
  totals[key].ms += ms;
  totals[key].maxMs = Math.max(totals[key].maxMs, ms);
}
console.table(totals);

Use case: remove per-frame churn in a scroll handler. Reusing a preallocated array and mutating row view-models avoids allocating thousands of short-lived objects every frame.

// Before: allocates a new array and a new object per visible row per frame
function visibleRows(data, start, count) {
  return data.slice(start, start + count).map((d) => ({ ...d, top: d.index * 32 }));
}

// After: fixed pool of row models reused every frame (zero steady-state allocation)
const pool = Array.from({ length: 60 }, () => ({ id: 0, label: '', top: 0 }));
function visibleRowsPooled(data, start, count) {
  for (let i = 0; i < count; i++) {
    const src = data[start + i];
    const row = pool[i];
    row.id = src.id;        // mutate in place instead of spreading
    row.label = src.label;
    row.top = (start + i) * 32;
  }
  return count; // renderer reads pool[0..count)
}

Verification and Regression Prevention

Re-record the same interaction with the same data volume. A successful fix shows fewer Minor GC slices per second (for a scroll handler, a drop from dozens to a handful), no Major GC longer than a few milliseconds inside the interaction, and a heap line whose teeth are shallower. Confirm that frames are actually recovered: in the Frames track, dropped or partially presented frames during the interaction should disappear.

Keep the win by tracking GC time as a number. Save a trace of the scripted interaction in CI (Puppeteer’s page.tracing.start() writes the same format), run the summary script above, and fail the build if total GC time for the interaction rises by more than a set margin or any single GC slice exceeds 16 ms. Pair it with the offline workflow in exporting and analyzing DevTools performance traces offline to keep the traces as build artifacts.

Heap line during the interaction Re-recording the same interaction with the same data volume, the heap line before the fix shows deep teeth from frequent Minor GC during scrolling. After reducing allocation, the teeth are shallower, Minor GC slices are much rarer, and dropped frames disappear from the Frames track. JS heap scroll interaction before: deep teeth, dozens of Minor GC/s after: shallow teeth, a handful

Frequently Asked Questions

Why does a GC slice appear inside a function that barely allocates?

GC is triggered when an allocation fails to fit, so the pause is attributed to whichever function happened to allocate at that moment. The real cause is the cumulative allocation rate of the task or page. Use allocation sampling to find which functions allocate most, rather than blaming the function that contains the GC slice.

How long is too long for a GC pause?

Anything that pushes a frame past its 16.7 ms budget during an animation or input is too long. As a rule of thumb, Minor GC slices should be a couple of milliseconds, and Major GC pauses inside interactions should stay in single-digit milliseconds. Longer major pauses usually indicate a very large or rapidly growing old generation.

Can I force GC to happen at a better time?

Not from page code in production — there is no standard API to trigger collection. You can make GC cheaper by allocating less during interactions and by avoiding sudden large promotions, which lets V8’s idle-time and concurrent collection do the work between frames instead of during them.