Capturing a chrome://tracing Timeline for Rendering

When an animation stutters but the Performance panel shows only a fat, unhelpful Composite Layers block with no obvious culprit, the cost has escaped onto threads the panel folds away β€” the compositor’s raster workers and the GPU process, downstream of the main thread’s commit. This guide is part of DevTools Performance Profiling, itself a section of Rendering Performance Metrics and Tooling, and it covers the case where you need the raw, per-thread chrome://tracing (Perfetto) timeline because the Performance panel has abstracted away exactly the events you need to see.

The Performance panel is a curated view: it merges dozens of internal trace categories into a handful of coloured bars and drops most compositor and GPU-process detail on the floor. chrome://tracing records the same underlying event stream with none of that editing β€” every TRACE_EVENT macro Chromium fires, tagged by process, thread, and category. That is the difference between β€œthe frame was slow somewhere in compositing” and β€œraster of tile (2,3) took 9.4ms on CompositorTileWorker3 and the swap missed the deadline by 2ms.”

Reproducing a Frame the Panel Can’t Explain

The clearest way to see the gap is to animate a property that forces re-rasterization every frame. box-shadow is the canonical offender: it cannot be handled by the compositor alone, so each frame repaints the layer and re-rasterizes its tiles.

// Minimal reproduction: animate a property that repaints + re-rasters every frame
const card = document.querySelector('.card')
let t = 0
function frame() {
  t += 0.016
  const blur = 20 + Math.sin(t) * 18
  // ❌ animating box-shadow re-rasters the layer's tiles on every single frame
  card.style.boxShadow = `0 0 ${blur}px 4px #23283a`
  requestAnimationFrame(frame)
}
requestAnimationFrame(frame)

In the Performance panel this shows up as a repeating Paint bar and a wide Composite Layers bar per frame, but the panel will not tell you which raster worker ran long or whether the GPU process was the bottleneck. The diagram below contrasts what each tool exposes for the same recorded frame.

Performance panel versus chrome://tracing granularity The panel collapses one frame into three bars while chrome://tracing splits it across main, compositor, raster and GPU threads. Performance panel view Recalculate Style / Layout Paint Composite Layers (opaque) 3 bars β€” no thread names, no GPU chrome://tracing view CrRendererMain: BeginMainFrame, Commit Compositor: Activate, RequestTiles CompositorTileWorker3: RasterTile VizCompositor: Draw, SwapBuffers GpuMain: GLES2, PresentToScreen every thread, every TRACE_EVENT, per process

What chrome://tracing Records and Where It Lives

Chromium is instrumented with TRACE_EVENT macros scattered through the renderer, compositor, and GPU code. When tracing is active, each thread writes its events into a per-thread ring buffer owned by that process’s TraceLog. Events are gated by category β€” cc for the compositor, gpu for the GPU process, viz for the display compositor, blink for style and layout, toplevel for message-loop tasks. If a category is not enabled at capture time, its macros compile to no-ops and nothing is recorded, which is why choosing categories up front matters.

When you stop the capture, the browser process collects every ring buffer from every child process, timestamps are reconciled against a shared clock, and the merged stream is handed to the Perfetto UI that chrome://tracing embeds. The frame lifecycle itself is emitted as an async event chain called PipelineReporter in the viz category: it spans a single frame from BeginFrame through Commit, Activate, Draw, and Swap, and it is the single most useful event when you want to know which stage blew the frame budget.

How trace events flow from threads into the merged timeline Per-thread ring buffers in each process are collected by the browser process and merged into one Perfetto timeline. Renderer process Main thread TRACE_EVENT Compositor TRACE_EVENT TileWorker TRACE_EVENT GPU process GpuMain TRACE_EVENT VizCompositor TRACE_EVENT per-thread ring buffers (TraceLog) Browser process collects all Merged Perfetto timeline

Capturing and Reading the Compositor Timeline

Open chrome://tracing, click Record, and choose Edit categories. For a rendering investigation, enable cc, viz, gpu, blink, toplevel, and disabled-by-default-devtools.timeline.frame; disable everything noisy you do not need, because a ring buffer that overflows drops the oldest events first. Reproduce the jank for two or three seconds, then Stop. If you prefer a scriptable capture that you can diff in CI, drive it through the DevTools protocol instead of the UI:

// Programmatic capture via the DevTools Protocol (puppeteer)
const client = await page.target().createCDPSession()
await client.send('Tracing.start', {
  categories: 'cc,viz,gpu,blink,toplevel,disabled-by-default-devtools.timeline.frame',
  transferMode: 'ReturnAsStream',
  bufferUsageReportingInterval: 500,
})
await runJankyAnimation(page)          // exercise the same path as the panel repro
await client.send('Tracing.end')
// the resulting trace opens in chrome://tracing or ui.perfetto.dev

Once the trace is open, press W/S to zoom and A/D to pan until one late frame fills the view, then click its PipelineReporter slice. The stage breakdown it prints is the whole story. Below is a labelled trace of the box-shadow frame: the main thread commits quickly, but a raster worker overruns and the swap slips past the vsync deadline.

[PipelineReporter β€” frame #914]  vsync budget: 16.6ms   total: 24.1ms  βœ— LATE
β”‚
β”œβ”€ CrRendererMain
β”‚    β”œβ”€ BeginMainFrame ................... 1.2ms
β”‚    β”œβ”€ UpdateLayerTree (Paint) .......... 3.1ms   ← box-shadow repaint recorded new tiles
β”‚    └─ ProxyMain::BeginMainFrame::commit  0.9ms
β”‚
β”œβ”€ Compositor (CrRendererCompositor)
β”‚    β”œβ”€ ActivateSyncTree ................. 0.4ms
β”‚    └─ RequestTiles ..................... queued 6 raster jobs
β”‚
β”œβ”€ CompositorTileWorker3
β”‚    └─ RasterTask tile(2,3) ............. 9.4ms   β–£ BOTTLENECK β€” shadow blur re-rastered
β”‚
└─ VizCompositor / GpuMain
     β”œβ”€ Draw ............................. 2.0ms
     └─ SwapBuffers β†’ PresentToScreen .... deadline missed by 7.5ms  βœ—

The PipelineReporter makes the attribution unambiguous: the cost is RasterTask, on a tile worker, caused by the shadow-blur repaint β€” not the main-thread work the Performance panel drew attention to. This is the same reasoning you apply when debugging paint flashing in DevTools, only with per-thread timing attached.

Swimlane timeline of one late frame across threads Four thread lanes show the frame pipeline stages and how the raster overrun pushes the swap past the vsync deadline. Main Compositor TileWorker3 GPU / Viz BeginMainFrame Commit RequestTiles RasterTask tile(2,3) 9.4ms Swap vsync deadline (16.6ms) late

The Fix: Getting Raster Off the Per-Frame Path

The trace pins the cost to per-frame rasterization, so the fix is to stop producing new tiles each frame. Instead of animating box-shadow, render the shadow once into a sibling layer and animate only its opacity β€” a property the compositor handles without touching raster. The shadow tiles are rasterized a single time; every subsequent frame is a pure compositor operation, exactly the class of work covered in off-main-thread rendering.

// BEFORE β€” repaints and re-rasters the card layer on every frame
function frame() {
  t += 0.016
  const blur = 20 + Math.sin(t) * 18
  card.style.boxShadow = `0 0 ${blur}px 4px #23283a` // ❌ new tiles rasterized each frame
  requestAnimationFrame(frame)
}

// AFTER β€” a static shadow overlay whose opacity animates on the compositor
// .card::after holds a fixed box-shadow and is promoted to its own layer.
// Only opacity changes, so no RasterTask runs after the first frame.
function frame() {
  t += 0.016
  const strength = 0.5 + Math.sin(t) * 0.5
  card.style.setProperty('--shadow-opacity', strength) // compositor-only, no re-raster
  requestAnimationFrame(frame)
}
/* AFTER β€” the shadow lives on a composited pseudo-element */
.card { position: relative; }
.card::after {
  content: "";
  position: absolute;
  inset: 0;
  box-shadow: 0 0 38px 4px #23283a; /* rasterized ONCE */
  opacity: var(--shadow-opacity, 1);
  will-change: opacity;             /* promotes to its own layer */
  pointer-events: none;
}

Re-capture the trace after the change: the RasterTask slices vanish from every frame except the first, the PipelineReporter total drops back under 16.6ms, and SwapBuffers lands before the deadline. Because the animation is now compositor-driven, it also survives a busy main thread β€” a property you can confirm alongside the techniques in reflow and repaint triggers.

Verification Checklist

Frequently Asked Questions

When should I use chrome://tracing instead of the Performance panel?

Reach for chrome://tracing when the Performance panel attributes a slow frame to a broad bucket like Composite Layers or GPU without naming the thread or task responsible. The panel merges categories and hides most compositor, raster-worker, and GPU-process detail; the raw trace keeps every TRACE_EVENT with its process, thread, and category so you can pin the cost to a specific RasterTask or SwapBuffers slice. For everyday main-thread scripting work, the panel is faster and enough.

Which trace categories matter for a rendering investigation?

Enable cc for the compositor, viz for the display compositor and the PipelineReporter frame lifecycle, gpu for the GPU process, blink for style and layout, and toplevel for message-loop tasks. Add disabled-by-default-devtools.timeline.frame for frame markers. Leave everything else off β€” an over-broad capture overflows the per-thread ring buffers and silently drops the oldest events you may need.

What is PipelineReporter and why is it the event to click?

PipelineReporter is an async event in the viz category that spans a single frame from BeginFrame through Commit, Activate, Draw, and Swap. Clicking one instance breaks the frame down by stage with per-stage timing, so you can see immediately whether the budget was blown on the main thread, in rasterization, or at swap time β€” no manual correlation of separate slices required.

Can I capture a chrome://tracing timeline in CI without the UI?

Yes. Drive the capture through the DevTools Protocol with Tracing.start and Tracing.end (available in puppeteer via a CDP session). You pass the same category list, exercise the janky path, and save the returned stream as a .json trace that opens in chrome://tracing or ui.perfetto.dev. This makes traces diffable across builds, which is the basis for automated frame-budget regression checks.