Layout, paint, and compositing
What the browser redoes when something on the page changes after the first render, why moving a box with transform is cheap while changing its width is not, and how to keep scrolling and animation inside a 16 ms frame.
Why a fast page can still feel slow
Picture a product listing page. It loads in under two seconds, it scores well on load metrics, and the team is proud of it. Then people start using it.
The filter drawer slides in with a visible stutter. The sticky header shakes a little when you scroll. Opening a "Show details" accordion on a card makes the whole grid jump in small, uneven steps.
Nothing here is slow to load. The page is slow to update. Every time something changes, the browser has to work out what the screen should look like now and draw it again.
If that work does not finish before the screen is ready for its next picture, the user sees the old picture for a little longer. That is a dropped frame, and a few dropped frames in a row is what people call jank.
The lessons "Critical rendering path" and "From URL to pixels" (both linked under Related) cover the first render: how HTML and CSS become a DOM, a CSSOM, a render tree, and the first pixels. This lesson starts where they stop. It is about every frame after the first one: what the browser redoes when the page changes, why some changes cost far more than others, and how to write code that stays smooth.
The frame budget: 16 ms, or less
A screen shows a new picture at a fixed rate called the refresh rate. Most laptops and many phones refresh 60 times a second, so a new frame is due every 1000 / 60 = 16.7 ms. Many newer phones and monitors run at 90 or 120 Hz, which leaves 11.1 ms or 8.3 ms per frame.
Your code does not get the whole budget. The browser also needs time in each frame for its own housekeeping and to hand the result to the GPU. A common rule of thumb from web.dev is to finish the work your page causes in a frame (your script plus the style, layout, and paint it triggers) in about 10 ms at 60 Hz, and less at 120 Hz.
On a mid-range Android phone, which can be four or more times slower than a developer laptop, the same code takes far longer.
Missing the budget once is barely visible. Missing it on every frame of a 300 ms animation is very visible: an animation meant to show 18 frames shows 9, and the motion looks like it skips. During a scroll or a drag it feels worse, because the content no longer follows the finger.
Two kinds of slow after load
It helps to split runtime slowness into two cases, because they have different fixes.
- Slow responses. The user clicks or types, and the next frame that shows the result arrives late. This is measured by INP, Interaction to Next Paint, a Core Web Vital where 200 ms or less counts as good. Long JavaScript tasks are the usual cause, but expensive layout and paint after the click add to it.
- Choppy motion. Scrolling, animations, and drags run at fewer frames per second than the screen can show. Here the cause is very often rendering work itself: the browser redoing layout or paint on every frame because of what the code changes.
Both cases come down to the same question: when your code changes something, how much of the rendering pipeline does the browser have to run again? Answer that well and most jank problems become easy to explain and to fix.
Check your understanding · Exercise 1
A drag handler runs 9 ms of JavaScript on every pointer move. On which screens is the drag likely to drop frames? Select all that apply.
The mental model: three ways back into the pipeline
On the first render the browser runs every stage in order: parse, style, layout, paint, composite. After that it keeps the results of each stage. When something changes, it does not start from scratch. It finds the earliest stage whose result is now wrong, marks that work as dirty, and runs the pipeline again from that stage onward.
So the cost of a change depends mostly on where it re-enters the pipeline. A change that enters early pays for every stage after it. A change that enters late skips the expensive ones.
The four stages that rerun
- Style (also called style recalculation): work out the final computed value of every CSS property for each element that might have changed. Triggered by class changes, inline style changes, attribute changes that selectors depend on, and DOM inserts and removals.
- Layout (Firefox and older docs call it reflow): work out the size and position of every box, in pixels. Text wrapping, flexbox, grid, and percentage sizes are all resolved here. One box's size can change its siblings, its parent, and everything after it.
- Paint (or repaint): record the drawing commands for each box: fill this rectangle, draw this text run, draw this shadow. In Chromium the commands are recorded into display lists, and turned into actual pixels (rasterized) in tiles, often on the GPU.
- Composite: take the separately painted layers, place each one with its current position, transform, opacity, and clip, and combine them into the final frame. This happens on a separate thread, the compositor thread, and the GPU does the blending.
The three paths
Almost every visual change falls into one of three paths. Learn these and you can predict the cost of most changes before you measure them.
| Path | Stages that rerun | Typical trigger | Relative cost |
|---|---|---|---|
| Layout path | Style, layout, paint, composite | width, height, margin, font-size, adding a DOM node, changing text | Highest: can touch many boxes |
| Paint path | Style, paint, composite | color, background-color, box-shadow, border-color | Medium: depends on the area repainted |
| Composite path | Style (sometimes), composite | transform and opacity on an element with its own layer | Lowest: no layout, no repaint |
Many articles say "reflow" for any rendering work, which makes costs hard to compare. In this lesson, layout always means geometry, paint always means drawing commands and pixels, and composite always means combining layers.
An everyday comparison: a printed noticeboard
Think of a noticeboard made of printed sheets pinned to a cork board. If you change the length of one notice, the sheets below it no longer fit: you must re-measure and reposition them (layout), print the changed ones again (paint), and pin everything back up (composite).
If you only change the colour of one heading, nothing moves. You reprint that one sheet and pin it back where it was (paint and composite).
And if you only want to slide a sheet a little to the left or make it see-through, you do not print anything at all. You just move the sheet that already exists (composite only). That last one is what transform and opacity do.
Cost also depends on how much is dirty
The path tells you which stages rerun. The scope tells you how much each stage has to redo. Browsers try to keep the scope small: changing the width of a box inside a fixed-size container may only lay out that container's subtree, while changing the font-size on html relayouts the whole page.
The same goes for paint. A colour change on a small button repaints a small rectangle. A change to a full-screen background repaints the whole viewport, which on a 4K screen is over 8 million pixels. Later chapters show how CSS containment lets you tell the browser where the scope ends.
Check your understanding · Exercise 2
Which of these changes need a layout pass in every major browser? Select all that apply.
Inside one frame, step by step
To reason about cost you need to know what the browser does between two screen refreshes, and on which thread. The HTML standard calls this the update the rendering step of the event loop. Browsers run it at most once per refresh, and only when something has changed.
Two threads that matter
- The main thread runs your JavaScript, event handlers,
requestAnimationFramecallbacks, style, layout, and paint recording. It does one thing at a time. If a script runs for 80 ms, nothing else on this list happens for 80 ms. - The compositor thread takes the painted layers and builds each frame from them. It also handles scrolling and certain animations by itself. Because it does not wait for the main thread, it can keep a page scrolling smoothly even while JavaScript is busy, as long as it already has what it needs.
Rasterizing (turning drawing commands into pixels) happens on yet more threads and, in Chromium, mostly in the GPU process. You rarely need those details. What matters is the split: main-thread work blocks everything, compositor-only work does not.
The order of work in a frame
- Input and tasks. Queued events (clicks, key presses, pointer moves) run their handlers. Timers and other tasks may run too. Continuous input such as pointer moves is usually coalesced to about one event per frame.
requestAnimationFramecallbacks. At the start of the rendering step,scrollandresizeevents fire (at most once per frame), and then code registered withrequestAnimationFrameruns, just before style and layout. This is the right moment to write visual changes for this frame.- Style. The browser recomputes styles for the elements marked dirty since the last frame.
- Layout. Boxes whose geometry may have changed are laid out again.
ResizeObservercallbacks run after layout; if they change something, style and layout run again in the same frame. - Paint. Changed areas get new drawing commands. In Chromium this phase also decides which elements get their own layers.
- Commit. The main thread hands the updated layer tree and drawing commands to the compositor thread, and is then free to run the next task.
- Raster and draw. Tiles are rasterized, the compositor combines the layers with their current transforms and opacity, and the GPU produces the frame. The frame appears at the next vsync, the moment the screen refreshes.
Steps 1 to 6 happen on the main thread and must all fit in the frame. Step 7 overlaps with the next frame's main-thread work, which is why browsers can keep a steady pace when each frame's main-thread part stays short.
One frame, step by stepStep 1 of 9: Input
Input: The click handler for "Show details" runs and adds a class to one card. Nothing is drawn yet: the browser only marks styles as dirty.
A worked example: opening an accordion
Back to the product grid. It shows 400 cards. Clicking "Show details" on one card adds a class that raises its max-height from 0 to 240px, animated with a CSS transition over 300 ms. On a mid-range laptop, one frame of that transition costs roughly this (illustrative numbers, but the shape is typical):
| Stage | Laptop | Mid-range phone (about 4x slower) | Why it costs this much |
|---|---|---|---|
| Style | 1 ms | 4 ms | One class changed, but the transition updates max-height every frame |
| Layout | 7 ms | 28 ms | The card grows, so every card below it in the grid moves |
| Paint | 3 ms | 12 ms | Every moved card is repainted, unless it has its own layer |
| Composite | 1 ms | 2 ms | Small: the compositor only combines layers |
| Total | 12 ms | 46 ms | Phone shows about 1 frame in 3 |
The laptop just about fits in 16.7 ms. The phone needs nearly three frames for each step of the animation, so the motion looks jerky. Notice what dominates: not the script, not the composite, but layout and paint, run 18 times during the 300 ms transition. The fix in a later chapter keeps the visual effect but moves it to the composite path.
Check your understanding · Exercise 3
Which statements about a single frame are correct? Select all that apply.
Which changes trigger layout, paint, or only compositing
The path a change takes depends on what the property affects. The rule is short: if it can change the size or position of any box, it needs layout. If it only changes how pixels look inside existing boxes, it needs paint. If it only moves, scales, or fades an already painted layer, it can be composited.
Applying the rule is mostly common sense once you ask "could this push another box?". padding can, background-color cannot. border-width can, border-color cannot. transform does not, because transforms are applied after layout and do not affect the space an element takes up.
filter row varies by browser and version; the last two rows are the only ones every major engine composites by itself.Changes that trigger layout
- Box geometry:
width,height,min-*andmax-*sizes,padding,margin,border-width,box-sizing. - Position and flow:
top,left,right,bottomon positioned elements,position,display,float,inset, flex and grid properties such asflex-basis,gap, andgrid-template-columns. - Text metrics:
font-size,font-family,font-weight,line-height,letter-spacing,white-space, and any change to the text itself. A web font finishing its download also relayouts the text that uses it. - The DOM itself: inserting, removing, or moving nodes, and toggling a class that changes any of the above.
- The viewport: resizing the window, rotating a phone, or the mobile browser's address bar appearing and disappearing, which changes the size of
vh-based boxes.
Changes that trigger paint but not layout
- Colour and fill:
color,background-color,background-image,background-position, gradients. - Decoration:
border-color,border-style(when the width stays the same),border-radius,outline,text-decoration. - Effects:
box-shadow,text-shadow, andvisibility(a hidden element still takes up its space, so nothing moves).
Paint cost is not flat. It grows with the area repainted and the complexity of what is drawn. A large box-shadow with a 40px blur on a card that moves every frame can cost more than a small layout change. Full-viewport gradients, many overlapping translucent shapes, and big blurred backgrounds are typical paint hogs.
Changes that can be composite-only
Two properties can run on the compositor in every major engine today: transform (translate, scale, rotate, skew, including the separate translate, rotate, and scale properties) and opacity. When the element has its own layer and the change is animated with a CSS animation, a CSS transition, or the Web Animations API, the compositor can run the animation by itself.
Beyond those two, engines differ, and they keep changing. Chromium can run filter animations on the compositor in many cases, and Chromium has been experimenting with compositor animations of background-color and clip-path. Treat those as a bonus you verify in DevTools, not as a guarantee you design around.
The hidden cost: style recalculation
Even a composite-only property goes through style when you change it from JavaScript, because the browser must recompute the element's styles before it knows what changed. Usually that is cheap. It becomes expensive when one change dirties the styles of many elements.
A common example is a CSS custom property set on :root from a pointermove handler, such as --mouse-x. Every element whose styles read that variable, directly or through inheritance, needs its style recomputed on every move. On a page with 5,000 elements that can be several milliseconds of style work per frame before layout even starts. Setting the variable on the one element that uses it keeps the scope small.
Check your understanding · Exercise 4
A hover effect on 200 cards changes box-shadow and lifts the card with margin-top: -4px. Which change is the expensive one, and why? Select all that apply.
Why transform and opacity animate smoothly
Moving a box with left and moving it with transform: translateX() look identical to the user. To the browser they are completely different jobs. One reruns layout and paint on every frame. The other reuses pixels it already has.
A layer is a picture the GPU already has
When an element sits in its own compositing layer, the browser paints it once and keeps the rasterized result on the GPU as a texture (a stored bitmap). To move that layer, the compositor only changes the matrix it uses when drawing the texture. To fade it, it only changes the alpha value used when blending. The GPU does this for millions of pixels in well under a millisecond.
No new layout is needed, because transforms and opacity do not change the space a box takes up. No new paint is needed, because the pixels inside the layer are the same: they are just drawn somewhere else, or more transparent.
Animations that survive a busy main thread
The second advantage matters even more. When a transform or opacity animation is declared with a CSS animation, a CSS transition, or the Web Animations API (element.animate()), the main thread can hand the whole animation to the compositor: start value, end value, duration, and easing. The compositor then computes each frame itself.
From that point, a long task on the main thread does not stop the animation. A spinner, a sliding drawer, or a fading toast keeps moving at full frame rate while React renders a big list. An animation of left cannot do this: every frame needs main-thread layout, so it freezes whenever the main thread is busy.
One subtlety: animating transform from JavaScript, by setting el.style.transform in a requestAnimationFrame loop, skips layout and paint but still needs the main thread on every frame to run your callback and recompute style. It is cheap, but it is not immune to long tasks. Prefer declarative animations (CSS or element.animate()) when the motion is known in advance, and keep rAF loops for motion that follows input, like a drag.
Animating layout changes with FLIP
Some animations really are layout changes: an item moving to a new spot in a reordered list, or a card expanding into a detail view. You cannot avoid the layout itself, but you can avoid running it on every frame. The FLIP technique (First, Last, Invert, Play) runs layout once and animates with transforms.
function flipMove(el: HTMLElement, applyChange: () => void) {
const first = el.getBoundingClientRect(); // First: read old position
applyChange(); // change the DOM or class
const last = el.getBoundingClientRect(); // Last: one forced layout
const dx = first.left - last.left;
const dy = first.top - last.top;
// Invert, then Play: start where it was, animate to where it is
el.animate(
[{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "none" }],
{ duration: 250, easing: "ease-out" },
);
}
Libraries use the same idea. Framer Motion's layout animations and many list-reorder animations measure before and after, then animate transforms. The View Transitions API (document.startViewTransition()) goes further: the browser takes snapshots of the old and new states and animates between them for you, mostly with transform and opacity (a snapshot whose size changes also animates its width and height).
Decision How should the product grid animate an expanding card without janking 400 cards?
What we pick: For a single short accordion on a small page, a height transition is fine. Once many elements move, or the page runs on low-end phones, switch to FLIP (or a library that does it). Use View Transitions for page-level or view-level changes where a snapshot animation fits.
Check your understanding · Exercise 5
A loading spinner rotates with @keyframes spin { to { transform: rotate(360deg) } }. While it spins, the page runs a 400 ms task to parse a big JSON response. What does the user see? Select all that apply.
Layout thrashing: reads, writes, and forced layout
Normally layout happens once per frame, after your code has finished. The browser lets your writes pile up (a class here, a width there), marks things dirty, and lays everything out in one pass before paint. That is cheap, because many changes share one layout.
But JavaScript can also read geometry: el.offsetHeight, el.getBoundingClientRect(), and so on. If layout is dirty when you read, the browser cannot give you a stale number. It has to stop your script, run style and layout right now, and only then return the value. This is a forced synchronous layout (Chrome DevTools labels it "Forced reflow").
The pattern that thrashes
One forced layout is often fine. The problem is a loop that alternates a write and a read. Each write dirties layout, each read forces it, so a loop over 300 elements runs 300 layouts in one frame instead of one. This is called layout thrashing.
// Thrashes: write, read, write, read...
for (const card of cards) {
const width = card.offsetWidth; // read: forces layout (dirty from last write)
card.style.height = `${width * 0.75}px`; // write: dirties layout again
}
// Batched: all reads, then all writes. One layout.
const widths = cards.map((card) => card.offsetWidth);
cards.forEach((card, i) => {
card.style.height = `${widths[i] * 0.75}px`;
});
// Best: no JavaScript at all
// .card { aspect-ratio: 4 / 3; }
What forces layout
Any API that returns a size, a position, or a scroll offset needs up-to-date layout. The common ones:
- Box metrics:
offsetTop,offsetLeft,offsetWidth,offsetHeight,offsetParent,clientWidth,clientHeight,clientTop,clientLeft. - Scroll metrics:
scrollWidth,scrollHeight,scrollTop,scrollLeft, and callingscrollTo(),scrollBy(), orscrollIntoView(). - Rectangles:
getBoundingClientRect(),getClientRects(),document.elementFromPoint(). - Computed styles:
getComputedStyle(el)forces style, and reading a layout-dependent value from it, such aswidthortop, forces layout. - Others that surprise people:
innerText(it depends on layout to know what is visible and where lines break),el.focus()(it may scroll the element into view), and selection and range APIs that report positions.
The full list differs between engines and changes over time; Paul Irish maintains a well-known list of what forces layout in each engine (see References). The rule to remember is simpler than the list: reading geometry after writing styles, in the same frame, forces layout.
How to fix it
- Remove the read. Often CSS can do the job:
aspect-ratio,flex,grid,position: sticky, and container queries replace a lot of code that measured and set sizes by hand. - Batch reads before writes. Read everything you need first, then write. In a component tree this is harder, because each component reads and writes independently; a scheduler such as the small
fastdomlibrary queues reads and writes and runs them in two phases per frame. - Read at the right time. Values read at the start of a
requestAnimationFramecallback usually come from the layout of the frame just shown, so they are free, as long as no event handler or earlier callback has written styles since. Do your writes after the reads. - Let the browser tell you.
ResizeObserverreports element sizes after layout, andIntersectionObserverreports visibility, both without forcing a layout. They replaceresizeandscrollhandlers that callgetBoundingClientRect()on every event.
Frameworks help, but do not solve it
React, Vue, and Svelte batch DOM writes into one commit, which removes most accidental thrashing from rendering itself. The risk moves to effects. In React, useLayoutEffect runs after the DOM is updated but before paint: reading getBoundingClientRect() there forces one layout (fine, and often the point).
Setting state from that measurement triggers a second render, commit, and layout before the frame is shown. Worse, if each of ten list items reads its own size and then writes a style straight to the DOM in its layout effect, the reads and writes interleave across items, and each read forces a new layout.
Check your understanding · Exercise 6
A scroll handler runs on every scroll event: it sets a class on the header, then reads header.getBoundingClientRect().height to position a banner. What happens and what fixes it? Select all that apply.
GPU layers and will-change
Everything so far depends on one condition: the element being animated has its own compositing layer. Without one, its pixels are painted into the same layer as whatever is behind it, and moving it means repainting that shared layer. So it is worth knowing when browsers create layers, what they cost, and how much control you really have.
What gets its own layer
Each engine has its own rules, and they change between versions. In Chromium, DevTools shows the reasons for each layer under the Layers panel. Common reasons there:
- An active CSS animation or transition, or a Web Animations API animation, of
transformoropacity(the layer usually goes away again when the animation ends). will-change: transform,will-change: opacity, or a 3D transform such astranslateZ(0)ortranslate3d().video,canvaswith WebGL or accelerated 2D, and often cross-origin iframes.- Fixed-position elements and scroll containers, so they can be moved during scroll without repainting.
- Overlap: an element drawn on top of a composited layer may need its own layer too, to keep the right stacking order. This is how one promoted element at the bottom of a page can cause dozens of extra layers, a problem called layer explosion.
What a layer costs
A layer's texture lives in GPU memory, at device pixels, at 4 bytes per pixel. A 400 x 300 CSS pixel card on a phone with a device pixel ratio of 3 is 1200 x 900 device pixels: about 4.3 MB. Twenty such cards promoted at once is over 80 MB, on a phone that may give the whole tab a few hundred megabytes.
Browsers soften this. Large layers are split into tiles, and only tiles near the viewport are rasterized. But each layer still adds memory, upload time to the GPU, and work for the compositor every frame. On low-end devices, too many layers is a real cause of jank and of tabs being killed for using too much memory.
What will-change does, and what it does not
will-change is a hint: "this property is about to change, prepare for it." For transform and opacity, browsers usually respond by promoting the element to a layer ahead of time. The benefit is narrow but real: the first frame of the animation does not pay for creating and painting the layer, which avoids a small hitch at the start.
- It is not a speed switch. It does not make layout or paint faster. Adding it to an element that animates
widthdoes nothing useful. - Browsers may ignore it, for example when memory is low. It is a hint, not an order.
- It changes behaviour even when ignored for layering.
will-change: transformcreates a stacking context and makes the element the containing block forposition: fixeddescendants, exactly like a real transform would. Aposition: fixedmodal inside it stops being fixed to the viewport.will-change: opacitycreates a stacking context too. - Promoted text can look different. Text in its own layer may be rendered with a different antialiasing method than text in the main layer, so it can look slightly thinner or blurrier.
// Add the hint just before the motion starts, remove it after.
drawerButton.addEventListener("pointerenter", () => {
drawer.style.willChange = "transform";
});
drawer.addEventListener("transitionend", () => {
drawer.style.willChange = "auto";
});
Decision How should an element get the layer it needs for a smooth animation?
What we pick: Start with no hints and measure. If the first frame hitches, add scoped will-change for that element. Keep permanent promotion for a handful of elements that move constantly, and never apply it to lists or with broad selectors.
Check your understanding · Exercise 7
A team adds will-change: transform to every product card so hover lifts feel smoother. What can go wrong? Select all that apply.
Shrinking the work, and finding it when it goes wrong
Sometimes the change must run layout or paint: new chat messages arrive, a table row updates, a font loads. Then the goal moves from avoiding the work to keeping it small. Three tools do most of that: CSS containment, skipping offscreen content, and a smaller DOM.
CSS containment: telling the browser where the work stops
Without hints, a browser has to assume that a change deep inside one widget might affect the layout of the whole page. The contain property is a promise that it will not.
With contain: layout, nothing inside the element can change the layout of anything outside it. With contain: paint, nothing inside draws outside its box. contain: content is shorthand for layout, paint, and style containment together.
contain: content is a promise that nothing inside the panel affects layout or paint outside it, so the browser can stop the work at the edge of the panel.Picture a dashboard with a live chat panel in the corner. Each new message changes the panel's content. With contain: content on the panel, and a fixed size for the panel itself, the browser can limit layout and paint to the panel's subtree instead of checking the whole dashboard. The promise has a price: content that tries to overflow a paint-contained box is clipped, so dropdowns and tooltips must render outside it.
Skipping offscreen work with content-visibility
content-visibility: auto goes one step further. For an element far outside the viewport, the browser skips style, layout, and paint of its contents entirely, and only does that work when the element comes close to the screen. On long pages such as feeds, docs, and comment threads, this can cut first render and later updates a lot. It works in all three engines: Chromium since version 85, Firefox since 125, and Safari since 18.0 (both 2024).
.feed-item {
content-visibility: auto;
/* placeholder height until it has been rendered once */
contain-intrinsic-size: auto 480px;
}
auto lets the browser remember the real size after the first render.Two traps. If the placeholder size is far from the real size, the scrollbar jumps as items render. And reading the geometry of a skipped item, for example with getBoundingClientRect(), forces the browser to render it, which undoes the saving. For very long lists, virtualization (rendering only the visible rows) is still the stronger tool; see the virtualization lesson.
Keeping scrolling on the compositor
In current browsers, scrolling is usually handled by the compositor: it moves the page's layers without asking the main thread. One thing breaks that: a touchstart, touchmove, or wheel listener that might call preventDefault(). The compositor then has to wait for the main thread to run the listener before it knows whether to scroll. If the main thread is busy, scrolling stalls.
Mark such listeners { passive: true } when they never cancel the scroll. The DOM standard, which current browsers follow, already makes touchstart, touchmove, wheel, and mousewheel listeners passive by default when they are attached to window, document, the html element, or body. Listeners on any other element are not.
scroll listeners are different: a scroll event cannot cancel scrolling, so it never blocks the compositor. Its cost is the main-thread work it does. Avoid reading layout in scroll handlers at all: use IntersectionObserver, position: sticky, or scroll-driven animations (animation-timeline: scroll()), which can run on the compositor where supported.
Failure modes you will meet in production
| Symptom | Likely cause | Fix |
|---|---|---|
| Animation stutters only on its first frame | Layer created and painted when the animation starts | Scoped will-change just before it starts |
| Spinner or drawer freezes while data loads | Animating left, height, or via a rAF loop | CSS or Web Animations API on transform and opacity |
| Scroll stutters over one panel only | Non-passive wheel or touchmove listener on that panel | { passive: true }, or remove the listener |
| Tab memory high, crashes on low-end phones | Layer explosion from broad will-change or overlap | Check the Layers panel; scope promotion |
| Typing in a large form feels laggy | A class on body or :root restyles thousands of elements per key | Toggle classes on the smallest element; reduce DOM size |
| Content jumps when a web font arrives | Font swap relayouts text with new metrics | Matched fallback metrics (size-adjust), preloading the font |
How to measure it
- Performance panel. Record with CPU throttling at 4x or 6x to imitate a mid-range phone, or profile a real phone with remote debugging. Purple blocks are style and layout, green blocks are paint. Forced reflows are flagged with a warning and a link to the JavaScript line.
- Rendering tab. "Paint flashing" highlights each repainted area in green. "Layer borders" outlines layers. "Frame rendering stats" shows a live overlay of frames being drawn and dropped.
- Layers panel. Lists every layer, its memory estimate, and why it was created.
- In the field. The Long Animation Frames API (Chromium) reports frames that took longer than 50 ms, with when style and layout started and, per script, how much forced style and layout it caused.
new PerformanceObserver((list) => {
for (const frame of list.getEntries() as PerformanceLongAnimationFrameTiming[]) {
for (const script of frame.scripts) {
if (script.forcedStyleAndLayoutDuration > 10) {
report({ source: script.sourceURL, forced: script.forcedStyleAndLayoutDuration });
}
}
}
}).observe({ type: "long-animation-frame", buffered: true });
Check your understanding · Exercise 8
A documentation site has pages with 3,000 sections. Updating a "copied" badge on one code block takes 25 ms of layout. Which changes help? Select all that apply.
Using it in a system design interview
Rendering cost is rarely the whole question, but it shows up inside many of them: an infinite feed, a drag-and-drop board, a chat app, a carousel, a data grid, a map with pins. Interviewers listen for whether you know what each interaction costs per frame and whether you can name the fix without guessing.
When to bring it up
- Anything that moves: carousels, drawers, modals, toasts, drag and drop. Say you animate
transformandopacityonly, declared with CSS orelement.animate()so they run on the compositor. - Anything long: feeds, chat histories, tables. Mention virtualization or
content-visibility: auto, and positioning rows withtransform: translateY()so recycled rows do not relayout their siblings. - Anything that measures: tooltips, popovers, masonry layouts, auto-growing text areas, sticky elements. Say you batch reads before writes, prefer
ResizeObserverandIntersectionObserver, and use CSS (position: sticky,aspect-ratio, anchor positioning where supported) before JavaScript. - Low-end devices: when the prompt mentions emerging markets or cheap Android phones, state the frame budget and that you would profile with a 4x to 6x CPU slowdown.
What to say, by level
| Level | What a strong answer includes |
|---|---|
| Junior | Layout vs paint vs composite in one sentence each; animate transform and opacity; 16 ms per frame at 60 Hz |
| Mid-level | Forced synchronous layout and read/write batching; why compositor animations survive long tasks; passive listeners; how to spot it in DevTools |
| Senior | Scope of invalidation (containment, content-visibility, DOM size, custom property scope); layer memory and will-change side effects; FLIP or View Transitions for layout animations; field measurement with Long Animation Frames and INP |
A short script for a drag-and-drop board: "While dragging, the card follows the pointer with transform: translate() set in a requestAnimationFrame callback, so each frame skips layout and paint. I read the card positions once at drag start, not on every move, to avoid forced layout. On drop I update the list order in state, and animate the other cards into their new slots with FLIP. On a 120 Hz screen the budget is about 8 ms per frame, so the move handler does nothing but store the pointer position."
Think first
An interviewer asks: "Your image carousel stutters on a mid-range Android phone. Walk me through how you would find and fix it."
First reproduce and measure: record the swipe in the Performance panel with 4x CPU throttling, or remotely debug the real phone. Look at each frame: is the time in scripting, in purple style and layout, or in green paint? Turn on paint flashing to see what repaints during the swipe.
Then match the cause. If slides move with left or margin-left, every frame relayouts and repaints the track: switch to transform: translateX(). If a scroll or touch handler reads offsetWidth after writing styles, batch the reads or cache the slide width from a ResizeObserver. If large images decode during the swipe, decode them ahead with img.decode() or load the neighbours early.
Finally, consider removing JavaScript from the motion: CSS scroll snap (scroll-snap-type: x mandatory) on a horizontal scroller gets native, compositor-driven scrolling for free. Verify the fix with the same throttled recording.
Summary
- After the first render, every change re-enters the pipeline at the earliest stage it invalidates: geometry reruns layout, paint, and composite; visual-only changes rerun paint and composite;
transformandopacityon a layer need only compositing. - A frame is 16.7 ms at 60 Hz and 8.3 ms at 120 Hz, and the browser needs part of it. Budget for mid-range phones, which are several times slower than a laptop.
- CSS or Web Animations API animations of
transformandopacityrun on the compositor and keep going through long main-thread tasks. Use FLIP or View Transitions to animate layout changes with one layout instead of one per frame. - Reading geometry after writing styles forces a synchronous layout. In a loop, that is layout thrashing: batch reads before writes, or replace the reads with CSS and observers.
- Layers cost GPU memory.
will-changeis a hint with side effects (stacking context, containing block for fixed children); scope it to the element and moment that need it. - When layout or paint cannot be avoided, shrink their scope with
contain,content-visibility: auto, smaller DOMs, and narrowly scoped class and custom property changes. - Which properties trigger what varies by browser and version. Measure in the Performance and Layers panels with CPU throttling, and in the field with Long Animation Frames and INP.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
What is the difference between layout, paint, and compositing, and which CSS changes trigger each?
Layout computes the size and position of every box. Paint records how each box is drawn and turns that into pixels. Compositing combines separately painted layers, with their transforms and opacity, into the final frame on the compositor thread.
Geometry changes such as width, margin, font-size, text changes, or DOM inserts trigger layout, then paint and composite. Visual-only changes such as color, background-color, or box-shadow skip layout and trigger paint and composite. transform and opacity on an element with its own layer only need compositing. The exact lists vary by browser and version, so I confirm in DevTools rather than trust a table.
Question 2
Why do transform and opacity animations stay smooth when the main thread is busy?
The element is painted once into its own layer, a texture on the GPU. Moving or fading it only changes how the compositor draws that texture: no layout and no repaint.
When the animation is declared with CSS or the Web Animations API, the main thread hands it to the compositor, which computes every frame itself. A long task on the main thread then cannot stop it. An animation driven from a requestAnimationFrame loop still needs the main thread each frame.
Question 3
What is layout thrashing and how do you fix it?
It is a loop that alternates DOM writes and geometry reads. Each write marks layout dirty and each read, such as offsetHeight or getBoundingClientRect(), forces the browser to lay out synchronously, so N iterations run N layouts in one frame.
Fixes, in order of preference: replace the measuring with CSS (aspect-ratio, grid, sticky); read everything first and then write; schedule reads and writes in separate phases (rAF, fastdom); and use ResizeObserver or IntersectionObserver, which deliver geometry without forcing layout. I find it in the Performance panel through the forced reflow warnings.
Question 4
When would you use will-change, and what are the risks?
For an element about to animate transform or opacity, where the first frame hitches because the layer is created at the start. I set it just before the animation and remove it on transitionend, or keep it permanently only on one or two elements that move constantly.
Risks: each layer costs GPU memory (width x height x DPR squared x 4 bytes), so applying it broadly causes layer explosion and can crash low-end tabs. It also creates a stacking context, and will-change: transform makes the element the containing block for position: fixed children. And it is only a hint: it does not make layout or paint cheaper.
Question 5
A feed of 2,000 posts janks when a new post is inserted at the top. How do you approach it?
Inserting at the top relayouts and repaints every post below, so the cost grows with the list. First, shrink what exists: virtualize the list, or at least use content-visibility: auto with a contain-intrinsic-size so offscreen posts are skipped.
Then make the insert cheap visually: keep the scroll position stable (scroll anchoring helps), show a "New posts" pill instead of shifting content under the reader, and animate any entrance with transform and opacity. Measure the frame with 4x CPU throttling before and after.
Practice it
These design problems lean on layout, paint, and compositing.
- Design a loading skeleton systemEasy
- Design a signature padEasy
- Design a throttled progress bar queueMedium
- Design a podcast player with a synced transcriptMedium
- Design an online chess gameMedium
- Design a virtualized listHard
- Design a no-code website builderHard
- Design a browser video editorHard
- Design a writing assistant like GrammarlyHard
References
- Rendering performance web.dev: the pixel pipeline and the three paths through it.
- Avoid large, complex layouts and layout thrashing web.dev: forced synchronous layout and how to batch.
- Stick to compositor-only properties and manage layer count web.dev: why
transformandopacity, and the cost of layers. - What forces layout / reflow Paul Irish's list of APIs that force layout, per engine.
- will-change MDN: what the hint does and why to use it sparingly.
- content-visibility MDN: skipping rendering of offscreen content.
- Inside look at modern web browser (part 3) Chrome for Developers: main thread, compositor thread, and raster in Chromium.
- Long Animation Frames API Chrome for Developers: finding slow frames and forced layouts from real users.
Read next
- The critical rendering pathEverything the browser must fetch and compute before the first pixels: HTML, render-blocking CSS, parser-blocking scripts, then style, layout, paint, and composite, and how to count, measure, and shorten it.
- From URL to pixelsEverything that happens between pressing Enter and seeing a page: DNS, TCP, TLS, the HTTP exchange, redirects, parsing, and subresources, with where the time goes on a real phone, so you know which part to fix when a page is slow.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Inside the browserHow a modern browser splits its work across processes and threads, why your JavaScript shares one main thread with layout, paint, and input, and how the event loop, workers, and OffscreenCanvas decide whether a page stays responsive.