Web Platform · Rendering Performance
Attributing Rendering Work: Reading Traces Without Guessing
How to tell script time from style, layout, paint, and raster in a frame trace, which entry types expose the same information to code, and how to sample without fooling yourself.
A slow page produces a single symptom — frames arrive late — and at least five possible causes: script on the main thread, style recalculation, layout, paint recording, or raster and compositing off the main thread. The causes are distinguishable only if the measurement preserves the boundaries between them. A stopwatch around a function call does not, because the expensive work may happen after the function returns, when the frame’s rendering steps run.
The portable layer: performance entries
The Performance Timeline exposes observations as typed entries, and the types relevant to rendering are stable enough to build tooling on:
paint, withfirst-paintandfirst-contentful-paintnames, giving astartTimerelative to navigation;largest-contentful-paint, reporting the largest content element’s paint time and, in itselementproperty, the element itself;layout-shift, reporting a score, whether the shift followed recent input, and the sources that moved;longtask, reporting main-thread tasks above 50 ms with attribution when available;event, reporting input interactions withprocessingStart,processingEnd, andduration, which separate input delay from handler time;long-animation-frame, reporting frames above 50 ms along with the scripts and rendering phases that made up the frame.
The important distinction is between entries that time a phase and entries that time an outcome. paint times an outcome; event times an outcome plus its internal segments. Only the frame-level entry type attempts to decompose a frame into its parts, and it does so with the caveat that the decomposition is produced by the engine’s instrumentation rather than by a specification of exact boundaries.
A trace is a nested timeline, and nesting carries the meaning
A trace file is a list of events with a name, a category, a timestamp, a thread identifier, and either a duration or a begin/end pair. The categories are the useful index: devtools.timeline covers the main-thread work the developer tools display, blink covers engine-internal detail, cc covers the compositor, and viz covers display compositing. Filtering by category is more reliable than filtering by event name, because event names change between engine versions.
Nesting is what makes a trace readable. A frame’s main-thread work appears as an outer event containing the rendering steps; style and layout appear as children in sequence; script appears as function call events inside whatever task invoked them. The rule for attribution is that a rendering event nested inside a script event was forced by that script, while a rendering event that is a sibling of script events belongs to the frame’s own rendering pass.
That distinction is the whole diagnostic. The same 60 ms of layout is a containment problem when it sits at the top level of the frame, and a forced-synchronous-layout problem when it sits inside a click handler. The trace shows which, and a duration measurement of the handler alone does not.
The frame boundary itself is marked by events from the compositor and display pipeline: a begin-frame event from the display, a main-frame event on the renderer, and a commit event that hands the layer tree across. Everything between the begin and the commit is the frame’s main-thread budget, and the gap between commit and the next begin-frame is where the compositor and raster threads work.
Separating script time from forced style and layout
The long animation frame entry type exists specifically to make the forced-work distinction available to code rather than only to a trace viewer. Each frame entry carries an array of script entries, and each script entry reports its duration and a separate duration for the style and layout work it forced.
new PerformanceObserver((list) => {
for (const frame of list.getEntries()) {
if (frame.duration < 50) continue;
for (const s of frame.scripts) {
console.log({
source: s.sourceURL,
fn: s.sourceFunctionName,
invoker: s.invoker,
total: Math.round(s.duration),
forced: Math.round(s.forcedStyleAndLayoutDuration),
});
}
}
}).observe({ type: 'long-animation-frame', buffered: true });
The forcedStyleAndLayoutDuration field is the measurement described in Forced Synchronous Layout made observable: a large value means the script read geometry after writing it, and the fix is restructuring rather than optimising the script’s own arithmetic. A frame whose scripts durations sum to a small fraction of the frame’s total duration has its cost in rendering, and the next step is the trace rather than the code.
The same decomposition applies to interactions. An event entry’s duration runs from the input’s timestamp to the next paint after the handler’s effects are visible, and the difference between processingStart - startTime and processingEnd - processingStart splits input delay from handler time. A handler that reports 20 ms of processing inside a 400 ms entry is not the problem; the queue in front of it is.
Reading a single slow frame
A worked reading, in the order the questions should be asked:
- Locate the frame in the trace by its begin-frame marker, and note the total main-thread span.
- Subtract idle time inside the frame. A frame can span 16.7 ms with 3 ms of work and still be late if it started late, which is a scheduling problem rather than a cost problem.
- Sum the nested rendering events: style, layout, pre-paint, paint. If they dominate, the frame is rendering-bound and the specific stage determines the remedy.
- If script dominates, check whether rendering events are nested inside script events. Nested means forced; sibling means the frame’s own pass.
- If neither dominates, the main thread is not the bottleneck; look at the compositor and raster tracks for the same frame interval, using the thread assignments given in Scrolling and the Compositor Thread.
Step 2 is the one most often skipped, and it produces the most misleading conclusions. A long gap between the display’s begin-frame and the start of main-thread work is not a rendering cost at all: it usually means the renderer was busy with an earlier task, or that frame production was throttled because nothing had changed.
Sampling and the comparisons that survive scrutiny
Single traces are anecdotes. The defensible measurement is a distribution over many frames or interactions, reported as a percentile rather than a mean, on a fixed device with a fixed power state and a warm cache. Two methodological hazards recur:
Observer overhead. Performance observers and performance.mark() calls cost time themselves. A measurement loop that marks every frame changes the thing it measures, especially on the main thread. Sampling a subset of frames, or using the entry types that the browser collects anyway, avoids the distortion.
Cache and first-run effects. The first load of a page includes parsing, compilation, and cold caches; a reload includes none of the network cost but may keep compiled code. Comparisons between two versions of a page are valid only if both are measured in the same cache state, and the state must be stated with the number.
For layout specifically, the useful derived quantity is not total layout time but layout time per frame combined with the number of frames that laid out at all. A page that lays out once per frame for 200 ms of a 2 s scroll is different from one that lays out for 10 ms every frame, and the two point at different fixes: containment for the first, and per-frame work reduction for the second.
Field data and lab traces answer different questions
A trace answers “what happened in this frame, on this machine”. Field measurement answers “how often does this happen to users”. The two are not interchangeable and neither substitutes for the other.
Field data arrives as aggregated distributions from real sessions, with the caveat that the device mix, network, and concurrent load are uncontrolled. It is the only way to know whether a problem is common, and the only way to see the long tail that a fast development machine never produces. A lab trace is the only way to see which stage caused a specific slow frame, because field data does not carry a call stack.
The practical loop is: use the field distribution to establish that a problem exists and how large the tail is; reproduce the tail condition in a lab trace with the same device class and a throttled CPU; fix the stage the trace names; re-measure the field distribution. Skipping the last step is how a fix that helps one frame shape leaves the tail unchanged.
One final caution about numbers in this domain: the thresholds that define “long” and “good” — 50 ms for a task, 200 ms for an interaction, 2.5 s for the largest contentful paint — are conventions tied to a metric definition and a percentile, not physical limits. They are useful for triage and useless as targets, because a page that sits exactly at a threshold has no margin for the device and network conditions that produce the tail.
References
- Performance Timeline — W3C
- Event Timing API — W3C
- Layout Instability API — WICG
- User Timing — W3C