What the browser does to produce each frame
When something on a page changes, the browser goes through up to five stages to draw the next frame. JavaScript runs, then style, layout, paint and composite. Which stages run depends on what changed, and skipping layout and paint is what makes a change cheap.
A 60 Hz display shows a new frame about every 16.7 milliseconds, and a 120 Hz display halves that. If the work for a frame takes longer, the browser misses the slot and scrolling or animation stutters. Each stage has a specific job:
- JavaScript changes the DOM or styles, from event handlers, timers or requestAnimationFrame callbacks.
- Style recalculation works out which CSS rules apply to each affected element and computes its final values.
- Layout, also called reflow, computes the size and position of every affected box.
- Paint records drawing instructions for text, colors, borders, shadows and images, and rasterization turns them into pixels.
- Compositing draws the layers in the right order on screen. It runs on a separate compositor thread, usually with help from the GPU.
Which changes trigger layout, paint or only compositing
The cost of a change depends on the property it touches. Geometry changes run the whole pipeline, appearance-only changes skip layout, and a few properties can skip both layout and paint.
- Layout, paint and composite all run when width, height, margin, padding, top, left, font-size or text content changes. Adding or removing elements and resizing the window do the same.
- Paint and composite run when only appearance changes, such as color, background-color, box-shadow or visibility.
- Only compositing runs for transform and opacity on an element that has its own layer.
Layout thrashing and forced synchronous layout
Layout tends to spread. Changing one element's width can move its siblings and resize its parent, so the browser may lay out a large part of the document. Pages with large DOMs pay more each time.
Browsers normally defer style and layout work and run it once, right before drawing the next frame. Reading a geometry property after a style change breaks that batching. Properties such as offsetWidth, offsetHeight, clientWidth and scrollTop, and the getBoundingClientRect() method, must return current values, so the browser runs layout on the spot.
That is a forced synchronous layout. Layout thrashing is the same thing inside a loop. Each iteration writes a style and then reads geometry, so the page is laid out once per iteration instead of once per frame. The fix is to do all the reads first, then all the writes.
For visual updates driven by scrolling or input, put the writes in a requestAnimationFrame callback so they run once, right before the next frame.
// Slow: every read after a write forces a fresh layout
for (const card of cards) {
card.style.height = container.offsetHeight / 2 + 'px';
}
// Faster: read once, then write
const half = container.offsetHeight / 2;
for (const card of cards) {
card.style.height = half + 'px';
}Animate transform and opacity instead of layout properties
Animating left, top, width or height reruns layout and paint on every frame, which is hard to fit in the frame budget. Animations of transform and opacity can be handled by the compositor alone. For CSS animations and transitions this often happens off the main thread, so the motion stays smooth while JavaScript is busy.
To move an element, use transform: translateX() rather than changing left. To grow it, scale it rather than animating width, if the content still looks right when scaled.
The will-change: transform declaration asks the browser to give an element its own layer ahead of time. Apply it only to the few elements you are about to animate, because every layer uses memory. Respect the prefers-reduced-motion media query for visitors who ask for less motion.
Long tasks block frames and input
The main thread runs JavaScript, style, layout and paint, one task at a time. A long task is one that runs for more than 50 milliseconds, and while it runs the browser can neither draw a frame nor respond to input.
Long tasks are a common cause of slow Interaction to Next Paint (INP) and of stutter during load. Break them up so the browser can render and handle input between chunks:
- Split work into smaller chunks and yield between them with await scheduler.yield() where it is supported, or setTimeout as a fallback.
- Move heavy computation, such as parsing large files or processing images, into a Web Worker. Workers run off the main thread but cannot touch the DOM.
- Load and run less JavaScript during startup, and defer work that the first view does not need.
- Record a trace in the Performance panel of Chrome DevTools to find long tasks and see whether the time goes to scripting, style, layout or paint.