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 StyleorLayoutbars 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
Layoutbar 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 Layersand 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.
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.
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 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.
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.
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:
- Open the Performance panel in an Incognito window so extensions do not pollute the Main track with their own scripting.
- 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.
- Set network throttling only if you are profiling load; for interaction profiling leave it off so network noise stays out of the trace.
- Enable Screenshots and Web Vitals checkboxes in the panel so the Frames track carries images and the timeline is annotated with LCP/CLS markers.
- 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.
- 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.
- 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
- Reading the DevTools Performance Flame Chart — decode the Main track’s colour coding, self-time versus total-time, and the long-task marker.
- Capturing a chrome://tracing Timeline for Rendering — the category set and per-thread reading needed to trace a frame from BeginFrame to SwapBuffers.
- Finding Dropped Frames in Frame Rendering Stats — use the live overlay to reproduce jank and pin each dropped frame to its Main-track cause.
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.
Related Guides
- Rendering Performance Metrics and Tooling — the parent section covering field measurement, Core Web Vitals, and lab tooling that these traces feed into.
- Core Web Vitals Measurement — turn the lab numbers from your traces into field metrics and CI thresholds.
- Forced Synchronous Layouts — the layout-thrash mechanism behind the most common repeated-Layout signature in a trace.
- Animation Performance Patterns — collapse a four-stage per-frame cost to a compositor-only one with transform and opacity.
- Layer Promotion and Composition — read the Layers panel to diagnose the layer-explosion cause of high Composite cost.