Reading the DevTools Performance Flame Chart

When a page stutters during scroll, the flame chart in the Performance panel shows exactly which main-thread task overran the 16.6ms frame budget and whether the cost was incurred by Recalculate Style, Layout, Paint, or Composite Layers — but only if you can read its track layout, stack depth, and color coding at a glance. This guide is part of DevTools Performance Profiling, which itself sits under Rendering Performance Metrics and Tooling; where the parent walks the end-to-end capture workflow, this page is a close read of the chart itself so that a bar’s position and hue immediately tell you which pipeline phase to blame.

The flame chart is not a picture of your code running. It is a projection of two independent things onto one canvas: the vertical axis is call-stack depth (who called whom), and the horizontal axis is wall-clock time (how long each frame took). Misreading either one sends you optimizing the wrong function. The three sections below take those axes apart, then a reproduction, an annotated trace, and a fix put them back together on a real regression.

The Track Layout: Where Each Cost Lands

A recording is split into horizontal tracks, stacked top to bottom, and each track answers a different question. The Frames track shows one screenshot per presented frame — a red corner marks a frame that missed the display’s refresh interval. The Main track is the flame chart proper: the main thread’s call tree, time-ordered left to right. Below it, Raster and GPU tracks show work handed off the main thread. Reading top-down, you first spot that a frame dropped (Frames), then scroll into the Main track directly beneath that timestamp to see why. The tracks share one timeline ruler, so a vertical line at any moment cuts through every thread at once.

Performance panel track stack aligned to one timeline Frames, Main, Raster and GPU tracks share a single time ruler so a dropped frame lines up vertically with the task that caused it. Track Timeline (shared ruler) Frames dropped Main Task 62ms Layout Raster tiles GPU composite

Stack Depth and Self Time: Reading a Single Bar

Inside the Main track, one bar is one function call. A bar drawn directly below another is a function the one above it called; depth grows downward as the call stack deepens. The width of a bar is its total time — itself plus every child. But the number that tells you whether this function is the culprit is self time: total time minus the summed width of its children, shown as the exposed sliver of the parent not covered by any child bar. A 40ms parent bar with a 39ms child underneath it has 1ms of self time; optimizing the parent is pointless — the child owns the cost. Selecting a bar populates the Summary tab with both numbers, and the Bottom-Up tab re-sorts the whole tree by self time so the true hot leaf floats to the top.

Self time versus total time in a nested call stack A wide parent bar with a nearly-as-wide child leaf shows that self time, not total width, identifies the expensive function. Deeper = called by the bar above onScroll — total 62ms updateSticky — total 60ms read offsetTop (leaf) — self 58ms self time = 2ms sliver The exposed edge of a parent not covered by a child = its self time. Sort Bottom-Up by self time to surface the real hot leaf.

The Color Legend: Mapping Bars to Pipeline Phases

Bar color is the fastest diagnostic in the chart because Chrome assigns a fixed hue per category of work, and each category maps to one phase of the rendering pipeline. Yellow is scripting (your JS, timers, event handlers). Purple is rendering — Recalculate Style and Layout. Green is painting — Paint and Composite Layers. Grey is system and idle. Because the mapping is phase-stable, you can read a frame’s dominant color before reading a single label: a frame that is mostly purple is spending its budget in style-and-layout, and repeated purple bars once per frame during an animation are the signature of a forced synchronous layout. A mostly-green frame points at paint invalidation; a mostly-yellow frame is a long task you can also catch with PerformanceObserver long-task entries.

Flame chart color to pipeline phase mapping A table pairing each bar color with the pipeline phase it represents and the DevTools event names in that category. Color Pipeline phase Event names Yellow Scripting Function Call, Timer Fired Purple Rendering Recalculate Style, Layout Green Painting Paint, Composite Layers Grey System / idle Task, Idle Read a frame's dominant hue before reading any label.

A Minimal Reproduction

The fastest way to learn the chart is to record a known-bad pattern and watch its signature appear. This scroll handler reads a geometry property inside the loop, forcing the browser to recompute layout on every scroll event:

<div id="bar">sticky header</div>
<div style="height: 4000px">scroll me</div>
<script>
  const bar = document.getElementById('bar');
  window.addEventListener('scroll', () => {
    for (const el of document.querySelectorAll('.item')) {
      el.style.transform = `translateY(${el.offsetTop}px)`; // BAD: reads offsetTop after writing style, flushing layout each iteration
    }
  });
</script>

Record a Performance profile, scroll for two seconds, and stop. The Frames track fills with red-cornered frames, and the Main track shows a tall purple Layout bar nested under the yellow scroll callback on every single frame.

Mechanism: Why the Chart Fills With Purple

The main thread processes one task at a time from a FIFO task queue; the scroll callback runs as one such task. Chrome keeps a dirty-layout flag on the render tree. Writing el.style.transform sets that flag, marking the cached box geometry stale. Reading el.offsetTop on the next line demands a value that depends on layout, so the engine cannot return a stale answer — it synchronously runs the style and layout pass right there, mid-loop, to satisfy the read. Because the write and read alternate every iteration, the flag is re-dirtied and re-flushed once per element, turning one layout per frame into N layouts per frame. That interleaving is what the flame chart renders as a stack of purple Layout bars buried inside the yellow callback.

The Annotated Trace

Here is the same recording as a labelled call tree. Read the indentation as stack depth and the self/total columns as the two time axes from the section above:

Frame @ 1240ms  ── PRESENTED LATE (62ms > 16.6ms budget)  [red corner]
└─ Task                                        total 62ms   self  1ms   [grey]
   └─ Event: scroll                            total 61ms   self  0ms   [yellow]
      └─ Function Call (onScroll)              total 61ms   self  2ms   [yellow]
         └─ [loop over 30 .item nodes]
            ├─ Layout                          total  2ms   self  2ms   [purple] ◄─ flush #1
            ├─ Recalculate Style               total  0.4ms self 0.4ms  [purple]
            ├─ Layout                          total  2ms   self  2ms   [purple] ◄─ flush #2
            │  ... repeated 30× ...
            └─ Layout                          total  2ms   self  2ms   [purple] ◄─ flush #30
   (Layout self-time summed across the frame ≈ 58ms — the whole budget overrun)

The tell is not one giant bar but many identical purple bars at the same depth — one flush per iteration. A single Layout per frame is healthy; thirty is thrash. This is the same signature covered from the layout angle in finding layout thrashing in DevTools.

The Fix: Batch Reads Before Writes

Split the loop into a read phase and a write phase so every geometry read happens while layout is clean, then every write happens afterward. Layout flushes once per frame instead of once per element:

// BEFORE — interleaved read/write, one forced layout per element
window.addEventListener('scroll', () => {
  for (const el of document.querySelectorAll('.item')) {
    el.style.transform = `translateY(${el.offsetTop}px)`; // forces synchronous layout flush each pass
  }
});

// AFTER — read all geometry first, then write; layout flushes once
let ticking = false;
window.addEventListener('scroll', () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    const items = document.querySelectorAll('.item');
    const tops = [...items].map(el => el.offsetTop); // READ phase: all reads while layout is clean
    items.forEach((el, i) => {
      el.style.transform = `translateY(${tops[i]}px)`; // WRITE phase: no read follows, so no mid-loop flush
    });
    ticking = false;
  });
}, { passive: true }); // passive listener keeps the compositor scrolling smoothly

Re-record after the change. The purple stack collapses to a single Layout bar per frame, the callback drops under 16.6ms, and the Frames track loses its red corners. Batching this way is the general technique detailed in how to batch DOM reads and writes, and the requestAnimationFrame wrapper additionally coalesces bursts of scroll events into one frame’s worth of work.

Verification Checklist

Frequently Asked Questions

What is the difference between self time and total time on a flame chart bar?

Total time is a bar’s full width — the function plus everything it called. Self time is the exposed portion of the bar not covered by any child bar: the time spent in that function’s own body. A wide bar with a nearly-as-wide child has almost no self time, so the child, not the parent, owns the cost. Sort the Bottom-Up tab by self time to find the true hot function.

Why are some bars in the Main track purple and others yellow?

Chrome color-codes bars by category of work, and each category maps to a rendering-pipeline phase. Yellow is scripting (JavaScript, timers, event handlers), purple is rendering (Recalculate Style and Layout), and green is painting (Paint and Composite Layers). Reading a frame’s dominant color tells you which phase spent the budget before you read a single label.

How do I tell that a frame was dropped in the flame chart?

Look at the Frames track above the Main track. Each presented frame is a screenshot; a frame that took longer than the display’s refresh interval to present is drawn with a red corner. Line that timestamp up vertically with the Main track — the task sitting directly beneath it is what caused the miss, because all tracks share one timeline ruler.

Why does the same purple Layout bar appear many times in one frame?

Repeated identical Layout bars at the same stack depth mean layout was flushed once per loop iteration — a forced synchronous layout. It happens when code reads a geometry property such as offsetTop after writing to the DOM in the same loop, forcing the engine to recompute layout to answer each read. Batching all reads before all writes collapses those bars into a single per-frame flush.