cf.completefrontend
My progress
FUNDAMENTALS · MODULE 2

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.

A bar chart of one frame's length, to scale. At 60 Hz a frame lasts 16.7 ms, at 90 Hz 11.1 ms, at 120 Hz 8.3 ms. A dashed line at 9 ms marks a drag handler's script: it fits inside the 60 and 90 Hz frames with little room left for rendering, and is longer than the whole 120 Hz frame.one frame, to scale60 Hz16.7 ms90 Hz11.1 ms120 Hz8.3 ms9 ms of script alone
The whole frame, not just your script, has to fit in these bars. Style, layout, paint, and compositing need their share too, so 9 ms of script is already too much at 120 Hz and tight at 90 Hz.

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
Three rows of the same four stages: style, layout, paint, composite. The layout path, for changes like width or margin, runs all four. The paint path, for changes like color or box-shadow, skips layout and runs style, paint, and composite. The composite path, for transform and opacity on their own layer, skips layout and paint, runs composite, and may skip style when the compositor runs the animation.layout pathwidth, marginStyleLayoutPaintCompositepaint pathcolor, box-shadowStyleLayoutPaintCompositecomposite pathtransform, opacityStyleLayoutPaintCompositedashed = skipped (or sometimes skipped)
The earlier a change enters the pipeline, the more stages it pays for. Moving work from the top row to the bottom row is most of what rendering performance is about.

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.

Which stages rerun after a change. Style may be skipped entirely when a compositor animation runs without touching the main thread.
PathStages that rerunTypical triggerRelative cost
Layout pathStyle, layout, paint, compositewidth, height, margin, font-size, adding a DOM node, changing textHighest: can touch many boxes
Paint pathStyle, paint, compositecolor, background-color, box-shadow, border-colorMedium: depends on the area repainted
Composite pathStyle (sometimes), compositetransform and opacity on an element with its own layerLowest: 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, requestAnimationFrame callbacks, 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.

A timeline of one 16.7 ms frame. On the main thread: input handlers from 0 to 1.5 ms, requestAnimationFrame callbacks to 2.5 ms, style to 4 ms, layout to 8 ms, paint to 10.5 ms, and commit to 11.5 ms, then idle. On the compositor thread and GPU: raster and draw from 11.5 to 15.5 ms. The screen shows the new frame at the next vsync, at 16.7 ms.vsyncnext vsyncmain threadinRSlayoutpaintCidlecompositordrawscreenprevious frame still shownnewin = input, R = rAF callbacks, S = style, C = commit
The main thread's part must end early enough for the compositor and GPU to finish before the next vsync. Layout and paint are usually the biggest blocks after your own script.

The order of work in a frame

  1. 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.
  2. requestAnimationFrame callbacks. At the start of the rendering step, scroll and resize events fire (at most once per frame), and then code registered with requestAnimationFrame runs, just before style and layout. This is the right moment to write visual changes for this frame.
  3. Style. The browser recomputes styles for the elements marked dirty since the last frame.
  4. Layout. Boxes whose geometry may have changed are laid out again. ResizeObserver callbacks run after layout; if they change something, style and layout run again in the same frame.
  5. Paint. Changed areas get new drawing commands. In Chromium this phase also decides which elements get their own layers.
  6. 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.
  7. 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

A frame in two rows. On the main thread: input, animation frame callbacks, style, layout, paint, commit. On the compositor thread and GPU: raster, draw, and the screen showing the frame at the next vsync. Each step lights up in turn with a running total of main-thread time.main threadInputrAFStyleLayoutPaintCommitcompositor thread and GPURasterDrawScreenmain thread so far: 1 ms of 16.7 ms
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.

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):

Illustrative costs per frame while the accordion animates. The layout stage dominates because one card's height changes the position of hundreds of others.
StageLaptopMid-range phone (about 4x slower)Why it costs this much
Style1 ms4 msOne class changed, but the transition updates max-height every frame
Layout7 ms28 msThe card grows, so every card below it in the grid moves
Paint3 ms12 msEvery moved card is repainted, unless it has its own layer
Composite1 ms2 msSmall: the compositor only combines layers
Total12 ms46 msPhone 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.

A matrix of nine CSS properties against three stages. Width and height, margin and padding, and font-size rerun layout, paint, and composite. Color, box-shadow, and visibility skip layout and rerun paint and composite. Filter skips layout, may or may not repaint depending on the browser, and composites. Transform and opacity only rerun composite, when the element has its own layer.layoutpaintcompositewidth, heightrunsrunsrunsmargin, paddingrunsrunsrunsfont-sizerunsrunsrunscolor-runsrunsbox-shadow-runsrunsvisibility-runsrunsfilter-variesrunstransform--runsopacity--runsassuming the element has its own layer for the last rows
Read across a row to see what a change costs. The 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-* and max-* sizes, padding, margin, border-width, box-sizing.
  • Position and flow: top, left, right, bottom on positioned elements, position, display, float, inset, flex and grid properties such as flex-basis, gap, and grid-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, and visibility (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.

Animate
Animating width: Style, Layout, Paint, Composite run every frame, about 13 ms of a 16.7 ms frame, on the main thread. Geometry: the box and its neighbours are laid out again, then repainted, every frame.Styleruns, 1 msLayoutruns, 7 msPaintruns, 4 msCompositeruns, 1 msmain threadcompositor + GPUper frameyour share: about 10 ms16.7 msabout 13 ms of rendering work every frame (illustrative)
Geometry: the box and its neighbours are laid out again, then repainted, every frame.

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.

A sequence diagram with three columns: main thread, compositor, GPU. The main thread commits the layers and the transform animation to the compositor once. The compositor uploads the layer texture to the GPU once. Then for frames 1, 2, and 3 the compositor tells the GPU to draw the same texture at x equals 0, 8, and 16 pixels. Meanwhile the main thread is busy with a long task and is never asked.Main threadCompositorGPUcommit: layers + animationupload texture, onceframe 1: draw at x = 0frame 2: draw at x = 8frame 3: draw at x = 16busy
After one commit, each frame is just a new position for an existing texture. The main thread can be busy for the whole animation without any frame waiting for it.

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.

Animate
With
Animating left with a CSS animation while a 200 ms task runs on the main thread from 120 to 320 ms: 12 of 24 frames are drawn in 400 ms, and the animation freezes for 200 ms.main threadlong task, 200 msframes drawn0 to 400 ms, one dot per 16.7 ms frame12 of 24 frames drawn: frozen for 200 ms
This animation needs the main thread on every frame, so it stops for as long as the task runs. The user sees a freeze, then a jump.

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.

ts
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" },
  );
}
FLIP: one layout to find the new position, then a compositor animation from the old position to the new one.

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?

Transition height or max-height

Add a class that changes max-height from 0 to a large value

Let a CSS transition animate it

Pros

  • Two lines of CSS
  • Content below moves naturally with the card

Cons

  • Layout and paint of everything below on every frame
  • Easing looks wrong when max-height is much larger than the real height

FLIP with transforms

Measure the cards, apply the final layout once, then animate each moved card from its old offset with transform

Fade the new content in with opacity

Pros

  • One layout instead of one per frame
  • Runs on the compositor and survives long tasks

Cons

  • More code, or a library
  • Scaling a card with scale() also scales its text, so you counter-scale children or only translate

View Transitions API

Wrap the DOM change in document.startViewTransition()

Style the transition with ::view-transition-* pseudo-elements

Pros

  • The browser does the FLIP work on snapshots
  • Very little code

Cons

  • Captured elements ignore pointer input while the transition animates; by default the whole page is captured, so keep it short
  • Snapshots of hundreds of named elements cost memory; check browser support for your audience

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.

ts
// 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; }
The same result three ways. The first loop forces a layout on every iteration after the first.
Before and after, for 4 cards. Before: read, write, then a forced layout before each of the next three reads, so 3 forced layouts inside one task. After: four reads, four writes, and one layout at the normal point in the frame.Before: read, then write, card by cardRWLRWLRWLRW3 forced layouts in one taskAfter: all reads first, then all writesRRRRWWWWLframe1 layout, when the frame needs it anywayR = read a size, W = write a style, L = layout
The same reads and writes in a different order. With 300 cards the first order runs 299 forced layouts in one task; the second still runs one.

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 calling scrollTo(), scrollBy(), or scrollIntoView().
  • Rectangles: getBoundingClientRect(), getClientRects(), document.elementFromPoint().
  • Computed styles: getComputedStyle(el) forces style, and reading a layout-dependent value from it, such as width or top, 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.

Loop
On a
200 cards, reads and writes interleaved, on a laptop: 200 layout passes, about 20 ms in one frame, about 1 frame dropped.RWLRWLRWLRWR = read offsetWidth, W = write style.height, L = layoutthis frame16.7 ms200 layout passes, about 20 msabout 1 frame dropped (illustrative costs)
Every read after a write forces a fresh layout, and each layout touches every card. The cost grows with the square of the number of cards.

How to fix it

  1. 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.
  2. 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 fastdom library queues reads and writes and runs them in two phases per frame.
  3. Read at the right time. Values read at the start of a requestAnimationFrame callback 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.
  4. Let the browser tell you. ResizeObserver reports element sizes after layout, and IntersectionObserver reports visibility, both without forcing a layout. They replace resize and scroll handlers that call getBoundingClientRect() 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.

A stack of four compositing layers for a phone page at device pixel ratio 3: a sticky header layer of 1125 by 168 device pixels, about 0.8 MB; a drawer with will-change transform of 900 by 2436, about 8.8 MB; a video layer of 1125 by 633, about 2.8 MB; and the root page layer, which is tiled so only tiles near the viewport are rasterized. The compositor blends them in order into the frame on screen.layers, top to bottomsticky header1125 x 168 device px, about 0.8 MBdrawer (will-change: transform)900 x 2436, about 8.8 MBvideo1125 x 633, about 2.8 MBroot layer: the pagetiled, only tiles near the viewportCompositorblends in orderScreenone frame4 bytes per device pixel
Each layer is a texture at 4 bytes per device pixel. Promoting one drawer costs more GPU memory than the header and the video together, which is why promotion should be scoped.

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 transform or opacity (the layer usually goes away again when the animation ends).
  • will-change: transform, will-change: opacity, or a 3D transform such as translateZ(0) or translate3d().
  • video, canvas with 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 width does 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: transform creates a stacking context and makes the element the containing block for position: fixed descendants, exactly like a real transform would. A position: fixed modal inside it stops being fixed to the viewport. will-change: opacity creates 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.
ts
// 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";
});
Scoped will-change: the layer exists during the animation, not for the life of the page.

Decision How should an element get the layer it needs for a smooth animation?

Let the animation promote it

Animate transform or opacity with CSS or element.animate()

Add no hints

Pros

  • No extra memory when idle
  • No stacking or containing-block side effects at rest

Cons

  • The first frame may hitch slightly while the layer is created and painted

Scoped will-change

Set will-change on hover, focus, or pointerdown just before the animation

Reset it to auto on transitionend or animationend

Pros

  • Layer ready before the first frame
  • Memory cost only while it matters

Cons

  • A bit of extra code
  • Side effects (stacking context, fixed children) apply while it is set

Permanent will-change or translateZ(0)

Set it in the stylesheet on the element

translateZ(0) is the older hack that forces a layer through a 3D transform

Pros

  • Simple
  • Reasonable for one element that moves very often, such as a sticky header or a frequently opened drawer

Cons

  • Memory held all the time
  • Applied to many elements (or with a * selector) it causes layer explosion and can make things slower

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.

A DOM tree: the page has a header, a product grid with two cards, and a chat panel with two messages. The chat panel has contain: content. When a new message arrives, only the chat panel and its messages are laid out and painted again; the header, the grid, and its cards are not rechecked.pageheadergridchatcardcardmsgmsgnot recheckedcontain: contentnew message: work stays here
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).

css
.feed-item {
  content-visibility: auto;
  /* placeholder height until it has been rendered once */
  contain-intrinsic-size: auto 480px;
}
Skipped items are sized by the placeholder; 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

SymptomLikely causeFix
Animation stutters only on its first frameLayer created and painted when the animation startsScoped will-change just before it starts
Spinner or drawer freezes while data loadsAnimating left, height, or via a rAF loopCSS or Web Animations API on transform and opacity
Scroll stutters over one panel onlyNon-passive wheel or touchmove listener on that panel{ passive: true }, or remove the listener
Tab memory high, crashes on low-end phonesLayer explosion from broad will-change or overlapCheck the Layers panel; scope promotion
Typing in a large form feels laggyA class on body or :root restyles thousands of elements per keyToggle classes on the smallest element; reduce DOM size
Content jumps when a web font arrivesFont swap relayouts text with new metricsMatched 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.
ts
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 });
Long Animation Frames (Chromium) can tell you, from real users, which script caused forced layouts.

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 transform and opacity only, declared with CSS or element.animate() so they run on the compositor.
  • Anything long: feeds, chat histories, tables. Mention virtualization or content-visibility: auto, and positioning rows with transform: 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 ResizeObserver and IntersectionObserver, 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

LevelWhat a strong answer includes
JuniorLayout vs paint vs composite in one sentence each; animate transform and opacity; 16 ms per frame at 60 Hz
Mid-levelForced synchronous layout and read/write batching; why compositor animations survive long tasks; passive listeners; how to spot it in DevTools
SeniorScope 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."

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; transform and opacity on 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 transform and opacity run 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-change is 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?

Question 2

Why do transform and opacity animations stay smooth when the main thread is busy?

Question 3

What is layout thrashing and how do you fix it?

Question 4

When would you use will-change, and what are the risks?

Question 5

A feed of 2,000 posts janks when a new post is inserted at the top. How do you approach it?

Practice it

These design problems lean on layout, paint, and compositing.

References

Read next