Debugging Rendering and Jank
Separate a frame that took too long from a frame that was never attempted, then find which pipeline stage the change is actually costing — with the forced-layout warning, paint flashing and layer borders as your three cheapest instruments.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
This feels janky — is the browser doing too much rendering work, or is something else holding the thread it renders on?
Someone is scrolling, dragging, typing or opening a menu, and the interface is not keeping up with their hand. They are not thinking about frames; they are thinking that the product feels cheap.
It is janky, so the animation is too heavy. Add will-change, promote things to their own layer, and reduce the visual effects until it feels smoother.
The most common cause of jank is not rendering work at all — it is a long task holding the main thread, so no frame is even attempted while it runs (Long Tasks). Making the animation cheaper does nothing to a frame that was never started.
- The most common cause of jank is not rendering work at all — it is a long task holding the main thread, so no frame is even attempted while it runs (Long Tasks). Making the animation cheaper does nothing to a frame that was never started.
- Promoting elements to layers trades main-thread work for memory and compositing cost, and doing it indiscriminately produces a page that is smooth in one interaction and slower everywhere else (Layer Explosion).
- Reading a layout-dependent property in a loop that also writes styles forces the browser to compute layout synchronously, repeatedly, discarding its own batching. The code looks innocent and the profile does not (Layout Thrashing).
- "Janky" from a user covers at least four mechanisms: input that is delayed before anything starts, a long frame, a dropped frame, and an animation that runs on the main thread while the main thread is busy. They have different fixes (Scroll and Input Latency).
- The same page can be smooth on a development machine and unusable on a mid-range phone, where the CPU is slower, the GPU is weaker, the display refreshes at a different rate and memory pressure is real (The Frame Budget).
What is actually happening
In the browser, not in the framework.
- A frame is produced by running the pipeline: style, layout, paint, composite. Not every frame runs every stage — the stages a change invalidates are decided by what changed (The Cost of a Change).
- The main thread must be free at the rendering opportunity for a frame to be produced at all. A task that runs long simply occupies it, and the browser has no way to interrupt (The Rendering Opportunity).
- A slow frame is a frame the browser tried to produce and could not finish in time: too much style recalculation, too much layout, too much paint. A blocked frame is a frame the browser never began, because script was still running. The Performance panel shows the difference clearly, and the two have almost nothing in common as bugs.
- A forced synchronous layout happens when script reads a property that depends on layout while a style change is pending. The browser must compute layout right there, out of its normal schedule. Devtools flags this specifically, and the flag is one of the highest-signal warnings in the whole tool (Layout Thrashing).
- Compositor-driven animation of
transformandopacitycan proceed on another thread while the main thread is busy, which is why those properties are so often recommended — and why the recommendation is conditional rather than universal (Cheap and Expensive Animation). - Paint cost is a function of area and complexity. A large blurred shadow, a filter, or a backdrop effect makes each rasterisation expensive and can enlarge the region that must be repainted well beyond the element that changed (Paint Commands).
What this makes the browser do
And which of it is avoidable.
- Style recalculation over the invalidated subtree, which scales with how much of the tree a change invalidates and with selector complexity (Style Invalidation, Selector Matching Cost).
- Layout over the boxes whose geometry could have changed, which for many changes is far more of the document than the element you touched (Normal Flow, Overflow and Margin Collapsing).
- Paint, then rasterisation of the affected tiles, then compositing of layers into a frame — some of which is off the main thread and some of which is not (Compositing Layers).
- Layer memory: every promoted layer costs GPU memory proportional to its size, and enough of them will cost more than the main-thread work they saved (Layer Explosion).
- Recording overhead: a Performance recording instruments all of this, so read ratios and shapes within one recording rather than comparing to an unprofiled load.
A slow frame and a blocked frame are different bugs
This is the distinction that decides the entire investigation, and it is visible in the first ten seconds of a recording. If the main-thread track shows one continuous block of script across the interaction, the browser had no opportunity to produce a frame at all: the interface is not slow, it is unable to respond, and every rendering optimisation you could make is irrelevant. If instead the track shows frames starting on schedule and each one spending too long in style, layout or paint, then rendering work really is the problem and the pipeline stage tells you where to look.
The timeline below shows both, in relative units. The first half is a blocked frame — one task, no rendering opportunities, input queued behind it. The second half is a slow frame — the browser begins each frame on time and overruns while doing genuine rendering work. A user describes both as "laggy"; the fixes have nothing in common (The Event Loop, Precisely).
- Input: pointer down — Queued — the handler cannot start until the thread is free.
- Long task (one handler) — Runs to completion. No frame is attempted for its whole duration (Tasks: The Unit That Cannot Be Interrupted).
- Frames not produced — This is the blocked-frame bug: nothing rendered, nothing dropped, nothing attempted.
- Frame A: style + layout — Now frames start on time. This one overruns in layout.
- Frame B: style + layout — Same shape repeating: a slow-frame bug, not a blocked one.
- Frame B: paint + composite — Read which stage dominates, then find the change that invalidates it (The Cost of a Change).
One recording answers the question. Continuous script with no frames means scheduling; frames that start on time and overrun means rendering work.
Which stage is this change costing?
Once you know rendering work is the problem, the question narrows to which stage, and that is decided by what changed rather than by how it was written. The table below is a debugging aid: find the row that matches the change your interaction makes, and it tells you which stage to expect in the recording. When the recording disagrees with the table, that disagreement is the finding — usually a forced layout, an unexpected invalidation, or a layer that was not promoted the way you assumed (The Cost of a Change).
The maybe answers are the honest ones and they are the most useful rows, because they tell you the cost depends on something you can inspect: whether the element is on its own layer, how much of the tree the change invalidates, and how large the affected area is (Style Invalidation).
| Change | style | layout | paint | composite | Why |
|---|---|---|---|---|---|
| Animating `transform` on a promoted element | no | no | no | yes | The compositor can move an already-rasterised layer without the main thread. If the recording shows layout here, the element is not actually on its own layer (Compositing Layers). |
| Animating `left` or `top` | yes | yes | yes | yes | Position participates in flow, so geometry has to be recomputed every frame and the result repainted (Normal Flow, Overflow and Margin Collapsing). |
| Changing `opacity` on an element that is not promoted | yes | no | maybe | yes | Opacity never affects geometry. Whether paint is needed depends on whether the engine could isolate the element into a layer. |
| Toggling a class that changes `background-color` | yes | no | yes | yes | Colour is a paint-only property, but the repainted area may be much larger than the element if effects overlap it (Paint Commands). |
| Reading `offsetHeight` after a style write | yes | yes | maybe | maybe | This is the forced synchronous layout devtools flags. In a loop it produces one layout per iteration (Layout Thrashing). |
| Inserting rows into a long list | yes | yes | yes | yes | Cost scales with how much of the tree is invalidated, not with the number of rows inserted — containment can bound it (CSS Containment). |
| Changing a custom property used across the page | yes | maybe | maybe | maybe | Everything downstream of the property must be recomputed; whether layout follows depends entirely on which properties consume it (Custom Properties). |
| Adding a large blurred shadow on hover | yes | no | yes | yes | Geometry is unchanged, but rasterising the effect is expensive and the invalidated region extends beyond the element (Cheap and Expensive Animation). |
caveat Every maybe depends on the page, not on the property: whether the element sits on its own compositing layer, how much of the tree the change invalidates, how large the affected region is, and which engine is executing it. Confirm in a recording rather than assuming the row.
Three overlays, and what each one proves
The rendering overlays are the cheapest instruments in frontend debugging and the least used. Paint flashing answers "what area is being repainted"; layer borders answer "how has this page been split up"; the forced-layout warning answers "which read is forcing the browser out of its own schedule". Each converts a vague complaint into a specific, visible fact in seconds.
The rows below pair what you see with what it actually means. The most valuable one is the last: frames dropped while the main thread looks idle, which sends people down completely the wrong path unless they know that rasterisation, GPU memory and compositing can drop frames on their own.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Paint flashing during a small state change | The whole viewport flashes, not the element that changed | The invalidated region is much larger than the element — an overlapping effect, a full-width ancestor, or a change to a property that affects a shared layer | Isolate the changing element with containment, or reduce the effect that enlarges the invalidation region (CSS Containment). |
| Layer borders enabled on a long list | Every row has its own layer | A promotion hint applied per item, often will-change or a 3D transform copied into a component | Promote the animating container, not its children, and measure layer memory afterwards (Layer Explosion). |
| Devtools flags a forced synchronous layout | A handler that reads geometry is disproportionately expensive | A read of a layout-dependent property with a pending style change, usually inside a loop | Batch all reads before all writes; measure once and apply many times (Layout Thrashing). |
| Style recalculation dominates every frame | Frames overrun before layout even begins | A change high in the tree invalidates a large subtree, or a selector matches far more than intended | Narrow the invalidation scope and check selector cost (Style Invalidation, Selector Matching Cost). |
| Interaction is fine after the first use | Only the first open of a menu or panel is janky | First-use cost: layout of newly inserted content, font loading, image decode, or a lazily loaded chunk (Lazy Loading) | Prepare the content ahead of the interaction, or make the first frame cheap and fill in afterwards. |
| Main-thread track looks idle and frames are still dropped | Scrolling stutters with no script running | Rasterisation, GPU memory pressure, or too many layers to composite in time | Reduce layer count and paint complexity; verify on the device that shows it, since this is where desktop and phone diverge most (The Frame Budget). |
| Smooth locally, janky in the field | No local reproduction at all | Device class, display refresh rate, memory pressure, or third-party script present only in production | Throttle the CPU, test on a real device, and check field interaction latency by device class (Interaction Responsiveness). |
How to build it
Most important first.
- First establish which of the two bugs you have. Look at the main-thread track during the interaction. Continuous script means a blocked-frame problem; frames that start and run long in style, layout or paint mean a slow-frame problem (The Frame Budget).
- For a blocked frame, the fix is scheduling: break the task up, yield, move the work to a worker, or do less of it. Nothing about the rendering code is relevant (Yielding and Scheduling, When a Worker Is Actually the Answer).
- For a slow frame, find the dominant stage in the recording and work backwards to the change that invalidates it (The Cost of a Change).
- Use the forced-layout warning as a first-class signal. It names the read, the write before it, and the code that did both — a fix that is usually a straightforward batching of reads before writes.
- Turn on paint flashing to see the repainted area. A whole screen flashing during a small change is a real finding and needs no interpretation (Paint Commands).
- Turn on layer borders when you suspect promotion problems: either something you expected to be a layer is not, or hundreds of things are (Compositing Layers).
- Reproduce on a throttled CPU, and on a real device for anything involving scrolling, touch or the compositor. Desktop emulation of a phone does not reproduce a phone (The Viewport and Device Pixels).
- Fix the cause, then re-record the same interaction and compare shapes. "It feels better" is the weakest possible evidence in this layer (Measure Before Optimising).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Jank is an accessibility problem before it is a polish problem: a main thread that is blocked delays focus moves, keyboard input and screen-reader announcements exactly as it delays pixels, and the user gets no visual cue that anything is happening (What the Main Thread Owns).
- Debug with a keyboard as well as a pointer. A drag interaction that is smooth with a mouse may have no keyboard equivalent at all, which is a bigger bug than the frame rate you were investigating (Keyboard Operability).
- Respect the reduced-motion preference while debugging and after. An animation that is only smooth on a fast device is also an animation some users have explicitly asked not to be shown (Contrast, Colour and Motion).
- Content that moves after paint is a stability problem, not a frame-rate problem, and it hurts screen-magnifier and switch users most — they lose their place or their target (Visual Stability).
- When you fix jank by yielding, re-check that focus and announcements still land in the right order: chunked work can reorder DOM updates relative to focus moves (Focus Management).
What can go wrong
- Adding
will-changeeverywhere, which creates layers that consume memory and can make the whole page slower while making one interaction smoother (Layer Explosion). - Moving a computation into a
requestAnimationFramecallback so it happens per frame instead of once, converting a one-off cost into a permanent one. - Chasing paint cost when the profile shows the frames were never attempted, because script never yielded.
- Debouncing the symptom — a resize or scroll handler that now runs less often but still forces layout when it does.
- Profiling the wrong interaction: recording the whole page load when the complaint is about dragging, or recording a warmed-up state after the expensive first pass has already happened.
- Optimising a frame that only exists in a recording. Devtools overlays themselves cost rendering work, and paint flashing on a marginal page can produce the jank you are looking for.
- Input can arrive during a long task and be processed after it, so the same interaction produces different orderings depending on what else was running (How an Event Is Dispatched).
- A compositor-driven scroll can run ahead of the main thread, so a handler that reads scroll position sees a value that is already out of date by the time it acts on it (Passive Listeners).
- An animation that starts before layout has settled measures the wrong geometry, and whether it does depends on whether a font, an image or a data response landed first (Images and Fonts).
- Two changes in the same task are batched into one frame; the same two changes separated by a forced layout are not — so an apparently equivalent refactor can change the number of frames the browser produces.
- Rendering is a side channel: what a page paints, and how long it takes, has been used to infer cross-origin state. This is why the browser refuses to expose certain rendering and timing information to script, and why some measurements are only available in devtools (The Browser Security Model).
- Third-party script participates in your rendering budget. A tag that lays out its own content on your main thread makes your interactions janky on a schedule you cannot see, and the profile attributes it to a script you did not write (Third-Party Scripts and the Supply Chain).
- Cross-origin iframes render in their own context. You cannot profile inside them and should not assume their work is free — they compete for resources and can compete for the main thread depending on the process split (The Multi-Process Browser).
- Injected content — an extension, an overlay, a translation tool — changes the DOM and therefore the rendering cost of a page that was fine when you tested it (Cross-Site Scripting).
- "Jank means the animation is too heavy." More often it means the thread that animates was busy doing something unrelated (Long Tasks).
- "
will-changemakes things faster." It tells the browser to prepare for a change, usually by promoting a layer. That helps a specific animation and costs memory everywhere it is applied (Cheap and Expensive Animation). - "The profile shows layout is expensive, so layout is the problem." Layout is expensive because something invalidated it — often a read in a loop. The stage with the time is rarely the code with the bug (Layout Thrashing).
- "It is smooth in devtools with the CPU throttled, so it is fine." Throttling models CPU speed. It does not model a weaker GPU, a different refresh rate, memory pressure, or touch input handling.
- "Frames dropped, so the main thread must be busy." Not necessarily: rasterisation, GPU memory pressure or a very large compositing workload can drop frames while the main-thread track looks idle (Compositing Layers).
Measuring it, and what changes in the field
- The Performance panel main-thread track: continuous script during the interaction means blocked frames; long style, layout or paint blocks per frame means slow frames (Long Tasks).
- Forced-layout warnings in the recording, which name both the read that forced it and the code responsible.
- The paint flashing overlay for repainted area, and layer borders for how the page has been split into layers.
- In the field, interaction latency by route and by device class, which tells you whether the local reproduction represents anybody (Interaction Responsiveness).
- Frame-level signals in the recording: which frames were dropped and whether the main thread was busy when they were (The Frame Budget).
- On a slow device, the same work per frame consumes proportionally more of the budget, so an interaction that is marginal on a laptop is broken on a phone (The Real Cost of JavaScript).
- At a higher display refresh rate, the per-frame budget is smaller, so a page can be smooth on one screen and janky on another with more capable hardware.
- With a large DOM, style and layout cost more per frame regardless of what changed, because more of the tree is a candidate for invalidation (What a Mutation Costs).
- Under memory pressure, layer promotion can be discarded and rasterisation re-done, so the same page behaves differently after an hour than it did on load (Long-Lived Clients and Version Skew).
- During scrolling on a touch screen, the compositor may be driving the page while the main thread lags behind it, which produces artefacts — blank tiles, stale content — that never appear with a mouse wheel on a desktop (Scroll and Input Latency).
- Yielding to unblock frames makes the total work take longer in wall-clock terms. That is usually the right trade — a responsive interface that finishes slightly later beats a frozen one that finishes sooner — but it is a trade, and for a batch job with no visible UI it may be the wrong one.
- Compositor-friendly animation avoids main-thread work at the cost of layer memory and of being restricted to properties that can be animated that way. It is a genuine constraint on what the design can do, not a free upgrade.
- Reducing paint cost often means giving up a visual effect. Deciding whether the shadow is worth the frames is a product decision that engineers should surface rather than make silently.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- BROWSER-SPECIFICThe instruments here are devtools features that differ substantially: the forced-layout warning, paint flashing and layer borders exist in Chromium in one form, Firefox exposes a different set of rendering overlays and its own profiler categories, and Safari names several stages differently and surfaces layer information under a separate tool. The mechanisms are shared; the affordances are not.
- ENGINE-SPECIFICWhich changes require which pipeline stages, and which animations can be handed to the compositor, are implementation decisions of Blink, Gecko and WebKit. They agree broadly on
transformandopacityand diverge on the edges, so verify a promotion assumption in the engine your users have (The Cost of a Change). - DEVICE-SPECIFICFrame budget is a function of the display's refresh rate and the device's CPU and GPU, so the same recording means different things on a high-refresh phone and a desktop monitor. A rendering bug that does not reproduce on your machine is not thereby a non-bug.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Programming Languages & Runtime Internals — what a sampling profiler can and cannot attribute, and why garbage collection, deoptimisation and inlining make some frames in a flame chart lie about who did the work.