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.
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.
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.
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.
Related Guides
- DevTools Performance Profiling β the workflow that ties trace capture to the flame chart and dropped-frame counting.
- Finding Layout Thrashing in DevTools β the main-thread counterpart when the cost is forced layout, not raster.
- Off-Main-Thread Rendering β why compositor-only animations keep running when the main thread is busy.
- Debugging Paint Flashing in DevTools β spotting the repaint that a trace later times to the millisecond.