DevTools Performance Profiling

A rendering regression that “feels janky” is not a bug report — it is a guess. The Performance panel exists to turn that guess into a timestamped, event-level record: which frame missed the 16.6ms budget, which main-thread task overran it, and whether the cost landed in Recalculate Style, Layout, Paint, or Composite Layers. This guide is the practical companion to Rendering Performance Metrics and Tooling: where that section explains what to measure in the field, this one explains how to capture and read a lab trace precisely enough that a code reviewer can see the regression in the flame chart instead of taking your word for it.

The three skills that make a trace conclusive are reading the flame chart’s track layout, dropping into chrome://tracing when the Performance panel abstracts away too much, and reading the Frame Rendering Stats overlay to count dropped frames as they happen. Each has its own focused walkthrough — Reading the DevTools Performance Flame Chart, Capturing a chrome://tracing Timeline for Rendering, and Finding Dropped Frames in Frame Rendering Stats — and this page ties them into one reproducible workflow.

Diagnostic Checklist: Signals That Justify a Profile

Before you record anything, confirm the symptom is a rendering-pipeline problem and not a network or script-download problem. A profile is only worth reading if you know which of these signals is present:

  • The Frames track shows red-cornered frames. In the Performance panel, the Frames track renders each presented frame as a screenshot; a partially-red border marks a frame that took longer than the display’s refresh interval to present.
  • The FPS meter (Frame Rendering Stats overlay) dips below the display’s refresh rate during interaction. The green-to-red bar and the “Dropped frames” counter move in real time — no recording required to spot the class of problem.
  • The Main track has a long task with a red hatched corner. Any task over 50ms is flagged; a task over 16.6ms during animation already guarantees a missed frame.
  • Recalculate Style or Layout bars repeat once per frame during a scroll or animation, rather than firing once. Repeated layout inside an animation loop is the signature of a forced synchronous layout.
  • A purple Layout bar sits directly under a yellow scripting bar — the browser was forced to flush layout mid-script because JS read a geometry property after writing to the DOM.
  • The Layers panel shows dozens of layers you did not intend to promote, inflating Composite Layers and GPU memory.

If none of these are present, the problem is likely upstream in the critical rendering path or in network waterfall territory — profile there instead.

Root Causes Behind a Janky Trace

Dropped frames trace back to a small number of distinct causes, and the flame chart tells them apart by where the cost lands and how often it repeats. Naming the cause is the whole point of profiling — the fix follows directly from it.

Decision tree from a dropped frame to its root cause A dropped frame branches by which pipeline event dominates the frame, mapping each to a named root cause. Dropped frame Yellow scripting dominates the frame Purple Layout repeats per frame Green Paint large / repeated Composite spikes many thin layers Long task / heavy JS Layout thrash Overpaint Layer explosion

Main-thread long tasks. A single JS task longer than one frame interval blocks the compositor from producing a new frame, no matter how cheap the actual rendering is. The flame chart shows a wide yellow (scripting) bar spanning what should have been several frames. This is the most common cause and the easiest to see.

Layout thrash. When JS interleaves DOM writes and geometry reads inside a loop, each read forces the browser to recompute layout synchronously. The trace shows alternating yellow and purple bars, one Layout per iteration. This ties directly to reflow and repaint triggers — the read of offsetHeight, getBoundingClientRect(), or scrollTop is the invalidating call.

Overpaint. A large or frequently invalidated paint region drives up the green Paint and Raster time. The Paint Flashing overlay (Rendering tab) shows exactly which pixels repainted; a full-viewport green flash on every animation tick means the invalidation rectangle is far larger than the element that actually changed.

Layer explosion. Over-promoting elements with will-change or translateZ(0) creates dozens of compositor layers. Composite Layers climbs, GPU memory balloons, and — counterintuitively — performance drops. The layer promotion decisions the compositor makes are visible in the Layers panel.

Reading the Performance Panel Flame Chart

The Performance panel is a stack of horizontal tracks, all sharing one timeline. Reading it well is mostly knowing which track answers which question. The Frames track (screenshots plus frame boundaries) tells you whether a frame dropped; the Main track (the flame chart proper) tells you why; the Interactions track anchors the when to a real user gesture; and the GPU and Raster tracks tell you whether work moved off the main thread as intended.

Track layout of the Performance panel against a shared timeline Stacked Frames, Interactions, and Main tracks aligned to one time axis, with a long task spanning two frame boundaries. Shared timeline (ms) 016.633.249.8 Frames dropped frame (33ms) Interactions pointerdown Main Task — Event: pointer (yellow, 47ms) handler callback Layout Recalc Style One 47ms task straddles two 16.6ms boundaries → the second frame is dropped.

Read the flame chart top-down and left-to-right. A yellow root task with nested blue (function call) and purple (Layout) children tells the whole story of one frame’s main-thread work. The width of a bar is wall-clock time; the nesting depth is the call stack. When you click a bar, the Summary tab breaks the selected range into Scripting / Rendering / Painting / System / Idle — a fast sanity check on where the frame budget went. The Bottom-Up and Call Tree tabs aggregate self-time so you can find the single function responsible without eyeballing bar widths. The full walkthrough of colour coding, self-time versus total-time, and the “long task” red-hatch marker lives in Reading the DevTools Performance Flame Chart.

Interpreting Recalculate Style, Layout, Paint, and Composite

The four rendering events are the pipeline itself, in order. Their colour and their cost per frame tell you which stage a regression sits in. The critical reading skill is understanding what invalidates each stage, because an early-stage invalidation forces every later stage to re-run.

The four rendering events and what invalidates each A left-to-right pipeline of Recalculate Style, Layout, Paint, and Composite, showing which property change re-enters at which stage. Recalc Style match selectors Layout compute geometry Paint record draw ops Composite assemble layers class change width / top / font color / box-shadow transform / opacity

The rule the diagram encodes: animating a property that re-enters at Layout (top, width, margin) pays for all four stages every frame; animating color or box-shadow skips layout but still pays Paint and Composite; animating only transform and opacity on a promoted layer skips straight to Composite, which the compositor thread handles without the main thread at all. That is why transform-and-opacity animation patterns are the canonical fix for jank — they collapse a four-stage cost into a one-stage one. The Recalculate Style cost specifically scales with selector complexity and the number of affected elements; reducing it is a matter of selector discipline covered under Style Calculation and Cascade.

A quick reference for what each event costs and when it fires:

Pipeline phase Triggering condition Typical cost signature
Recalculate Style Class/attribute/inline-style change; DOM mutation Wide when many elements match a complex selector
Layout Geometry read after write; changing width/height/position Repeated bars = layout thrash
Paint Changing color, shadow, background, border-radius Large green bar = big invalidation rect
Composite Layers New/removed layer, changed transform on a layer Scales with layer count, not element count

Going Deeper with chrome://tracing

The Performance panel deliberately abstracts. When you need the raw event stream — every BeginFrame, DrawFrame, RasterTask, and the exact thread each ran on — chrome://tracing (or its successor, the Perfetto UI) is the tool. It records categorised trace events from every thread in the browser process, not just the renderer main thread, so it is the only way to see, for example, that a frame stalled waiting on the GPU process rather than on your JS.

Per-thread lanes in a chrome://tracing capture Compositor, renderer main, raster, and GPU threads shown as parallel lanes producing one presented frame. CrRendererMain Compositor CompositorTileWorker GPU Recalc + Layout Paint (record) BeginFrame DrawFrame RasterTask x2 SwapBuffers One frame crosses four threads; a stall on any lane delays SwapBuffers and drops the frame.

Reading a chrome://tracing capture is a matter of following one frame across lanes: BeginFrame on the compositor kicks off main-thread style/layout/paint, tiles hand off to the raster workers, and the GPU thread’s SwapBuffers marks the frame presented. The gap between two DrawFrame events is your real frame interval — if it exceeds the display refresh period, that is a dropped frame with a per-thread explanation attached. Capturing with the right category set (disabled-by-default-devtools.timeline, blink, cc, gpu) and reading these lanes is a discipline in itself, covered step by step in Capturing a chrome://tracing Timeline for Rendering.

Frame Rendering Stats and Counting Dropped Frames

The fastest feedback loop needs no recording at all. Open the Rendering tab (Command Menu → “Show Rendering”) and enable Frame Rendering Stats. The overlay pins to the top-left of the viewport and shows live FPS, GPU memory, and a running dropped-frames count. Interact with the page and watch the counter: if it climbs during a scroll or animation, you have reproduced the problem and can now record a targeted trace instead of a blind one.

Frame timeline showing presented versus dropped frames against the budget line A row of frame bars where those exceeding the 16.6ms budget line are marked as dropped. Frame durations vs 16.6ms budget 16.6ms budget line dropped dropped dropped dropped Yellow bars breach the budget line and are counted as dropped; green bars presented on time.

The overlay classifies each frame as fully presented, partially presented, or dropped, and the Performance panel’s Frames track uses the same classification after the fact. Because the counter is cumulative, the reliable technique is to note the count, perform one bounded interaction (a single flick-scroll or one animation cycle), and read the delta — that isolates the dropped frames to that gesture. Turning that raw count into a per-interaction dropped-frame ratio, and correlating it with the responsible Main-track task, is the subject of Finding Dropped Frames in Frame Rendering Stats.

A Reproducible Capture Methodology

An unthrottled trace on a fast workstation proves nothing — the frame that drops on a mid-range Android phone runs comfortably on your M-series laptop. A trace is only evidence if the capture conditions are fixed and stated. Follow the same procedure every time:

  1. Open the Performance panel in an Incognito window so extensions do not pollute the Main track with their own scripting.
  2. Set CPU throttling to 4× or 6× slowdown (Performance panel gear icon → CPU). 4× approximates a mid-tier phone; 6× a low-end one. State the multiplier in every report.
  3. Set network throttling only if you are profiling load; for interaction profiling leave it off so network noise stays out of the trace.
  4. Enable Screenshots and Web Vitals checkboxes in the panel so the Frames track carries images and the timeline is annotated with LCP/CLS markers.
  5. Record the shortest window that contains the symptom. Click record, perform the one interaction, stop. A 3-second trace is readable; a 30-second one is not.
  6. Reload-profile for load, interact-profile for runtime. Use the reload-and-record button only when the regression is in startup; use plain record for scroll/animation/input jank.
  7. Capture on the same commit twice — once before, once after the fix — with identical throttling, and diff the two flame charts. That before/after pair is the artifact you attach to the PR.

The full validation loop — turning these lab traces into a CI gate — connects to Core Web Vitals Measurement for the field side of the same numbers.

Before and After: Making a Regression Visible

The point of the methodology is that the fix shows up in the trace. Consider a scroll handler that reads layout inside the event, forcing a synchronous flush every frame.

// BEFORE — layout thrash, one forced reflow per scroll event
function onScroll() {
  for (const card of cards) {
    const top = card.getBoundingClientRect().top; // forces synchronous layout flush (read after prior writes)
    card.style.opacity = top < window.innerHeight ? '1' : '0'; // write invalidates layout again
  }
}
window.addEventListener('scroll', onScroll); // runs on the main thread every scroll tick

In the trace this appears as a yellow Event: scroll task with a purple Layout bar nested inside it, repeating once per frame — the exact “purple repeats per frame” signature from the root-cause tree. The Frames track shows red-bordered frames alongside it.

// AFTER — batch reads, then batch writes; layout flushes at most once
function onScroll() {
  // Phase 1: read all geometry up front (single layout flush for the whole batch)
  const tops = cards.map((card) => card.getBoundingClientRect().top);
  // Phase 2: write only; no interleaved reads, so no forced reflow
  requestAnimationFrame(() => {
    cards.forEach((card, i) => {
      card.style.opacity = tops[i] < window.innerHeight ? '1' : '0'; // deferred to the frame's rendering step
    });
  });
}
window.addEventListener('scroll', onScroll, { passive: true }); // passive: no scroll-blocking, compositor scrolls freely

The after-trace collapses the per-frame Layout bars to a single flush, the scroll task drops under 16.6ms, and the Frames track turns green. Both traces, captured at 4× CPU throttle, are the before/after evidence — the reviewer sees the purple bars disappear rather than trusting a claim. The mechanism, reading versus writing geometry and where the flush lands, is detailed under forced synchronous layouts.

Edge Cases: React, Vue, and Next.js

Framework abstractions change what the flame chart shows and where the cost hides. Profiling a framework app without accounting for this leads you to blame the wrong bar.

React. In development builds, the Main track is thick with workLoopConcurrent, commitWork, and profiler overhead that does not exist in production. Always profile a production build. With Concurrent rendering, React can yield between components, so a single logical update appears in the trace as several smaller tasks broken by scheduler MessageChannel callbacks — that is intentional and healthy, not fragmentation to fix. The genuine cost to watch is the commit phase, where React writes to the DOM; a large commit triggers one big Recalculate Style and Layout. Use the React Profiler flamegraph to find the component, then the Performance panel to confirm the pipeline cost. Note that useLayoutEffect runs synchronously before paint and reads layout — an expensive one shows up as scripting wedged between Layout and Paint.

Vue. Vue’s reactivity flushes DOM updates in a microtask via nextTick. If application code reads geometry synchronously right after a reactive mutation, it forces layout before Vue’s batched patch has flushed, defeating the batching — the trace shows an extra Layout bar the framework tried to avoid. Reading in a nextTick callback keeps the flush single.

Next.js. Hydration is the signature cost. On first load the Main track shows a long scripting task as Next hydrates the server-rendered HTML; this often contains the page’s worst long task and can delay INP badly. Profile with the reload-and-record button and CPU throttling to see hydration realistically, and consider whether the route needs full hydration or can defer it. Streaming SSR spreads hydration across several tasks — good for INP, but it means the cost is scattered across the flame chart rather than in one bar.

Framework Trace artifact to ignore Real cost to profile
React Dev-build profiler/StrictMode double-invoke Commit-phase Layout; useLayoutEffect reads
Vue Batched microtask patch bars Sync geometry read defeating nextTick batching
Next.js SSR HTML already painted Hydration long task delaying INP

Metric Targets

Use these thresholds to decide whether a trace passes. Measure interaction cost at 4× CPU throttle unless stated.

Metric Target Measurement method Passing trace looks like
Frame duration (animation) ≤ 16.6ms (60Hz) Performance panel Frames track All green frames, no red borders
Longest main-thread task < 50ms Main track long-task marker No red-hatched task corners
Dropped frames per interaction 0 Frame Rendering Stats delta Counter unchanged after one gesture
Recalculate Style per frame < 2ms Click the bar → Summary One thin purple bar per update
Layout per animation frame 0 repeated Main track during animation No per-frame Layout bars
INP (hydration/interaction) < 200ms Web Vitals lane / field RUM Interaction resolves within one budget window

In This Topic

Frequently Asked Questions

What CPU throttling multiplier should I use when profiling?

Use 4× slowdown to approximate a mid-tier Android phone and 6× for a low-end device. The important discipline is consistency: capture the before and after traces at the same multiplier, and state it in your report. An unthrottled trace on a fast workstation hides frames that drop on real user hardware, so it cannot be used as evidence.

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

Reach for chrome://tracing (or the Perfetto UI) when the Performance panel’s abstraction hides the cause — for example when a frame drops but the Main track looks idle, which usually means the stall is on the raster or GPU thread. The tracing tool records categorised events from every thread in the browser process, so it can show a stall waiting on SwapBuffers that the Performance panel never surfaces.

Why do I see extra work in the flame chart that disappears in production?

Development builds of frameworks like React add profiler instrumentation, StrictMode double-invocation, and un-minified function names that inflate the Main track. Always profile a production build. If you must profile a dev build, treat the framework’s own scaffolding bars as noise and focus on the commit-phase style and layout events, which are present in both builds.

How do I know a dropped frame was caused by my code and not the browser?

Align the three tracks: a dropped frame in the Frames track should sit directly above a Main-track task wider than the frame interval, and often above an entry in the Interactions track. If the Frames track shows a drop but the Main track is idle underneath it, the cost is off the main thread — capture a chrome://tracing timeline to see whether raster or GPU work overran.

Does the Frame Rendering Stats overlay affect the measurement?

The overlay itself is cheap and rendered by the browser outside your page’s main thread, so its impact on your frame budget is negligible. It is meant for live, no-recording feedback: watch the dropped-frames counter while you interact, and once you can reproduce a climb, record a targeted Performance trace to find the responsible task.