Animation on a screen is a flipbook. A 60Hz display shows a new picture every 16.7ms, and the browser has to have the next one ready in time. When it isn't, the old picture stays up for another round, and you see a stutter.
Below, two boxes move the same way. The top one is moved by JavaScript, which sets left on every frame. The bottom one is a CSS animation on transform. Press the button. It keeps the browser's main thread busy for a full second.
The top box freezes, then jumps. In most browsers the bottom one carries on as if nothing happened. They look identical until the main thread gets busy, and then they don't.
What happens in one frame
A web page has one main thread. It runs your JavaScript, handles events, and also does most of the work of turning HTML and CSS into pixels. It can only do one of those at a time.
The HTML spec describes the loop. The browser runs tasks, like a click handler or a timer, one after another. Between them, whenever the display is ready for a new picture, it gets a "rendering opportunity". The spec's own example is a 60Hz screen, with an opportunity "every 60th of a second (about 16.7ms)". At that point it runs your requestAnimationFrame callbacks, then brings the page up to date.
Bringing it up to date is a pipeline. The usual names, from web.dev, are:
- Style. Work out which CSS rules apply to each element.
- Layout. Work out where every box goes and how big it is.
- Paint. Record the drawing commands: this text here, this border there.
- Composite. Rasterise those commands into pixels, often in separate layers, and stack the layers into the final picture.
Chrome's RenderingNG architecture splits this more finely, with stages like pre-paint, layerize and raster. The important split is where things run. Style, layout and paint happen on the main thread. Then the result is committed to a separate compositor thread, and raster and drawing happen away from the main thread, mostly on the GPU.
The budget
At 60Hz, everything for a frame has to fit in 16.7ms. On a 120Hz screen, like a ProMotion iPhone or many Android phones, the budget halves to 8.3ms. And the browser needs some of that time for itself. web.dev's rule of thumb is to keep your own work to about 10ms of a 60Hz frame.
A dropped frame isn't a blank screen. It's the previous picture held for twice as long, so the motion pauses and then jumps to catch up. Your eye is very good at noticing that.
Not every property costs the same
What changes decides which stages have to run again. Pick a property and see which parts of the pipeline it wakes up.
- Geometry, like
width,height,topormargin, needs layout. Layout can spread. A wider box can push its siblings, re-wrap text and resize its parent. Then everything that moved has to be painted again. - Appearance, like
color,background-colororbox-shadow, skips layout but needs paint. Large, blurry shadows are especially slow to paint. transformandopacityusually need neither. If the element has its own layer, the pixels already exist, and the compositor only moves the layer or changes how see-through it is.
That's why the advice is always to animate transform and opacity. It isn't a style preference. They're the most reliable way to skip both expensive steps.
Why the transform kept moving
Skipping layout and paint is half of it. The other half is where the animation runs.
When transform or opacity is animated with a CSS animation, a CSS transition or the Web Animations API, the browser can hand the whole animation to the compositor thread. From then on the compositor works out each frame by itself, so the main thread can be completely stuck and the animation still runs. Chrome's notes on accelerated animations list opacity, filter and transform as the properties it accelerates by default.
The top box in the demo can't get this, even though it only moves. Its position is decided by JavaScript in a requestAnimationFrame callback, and that callback runs on the main thread. If the main thread is busy, the callback doesn't run, and nothing else knows where the box should be.
The cost of this is memory. Each composited layer is a texture of pixels the GPU has to keep. will-change: transform asks the browser to make a layer ahead of time. MDN is blunt about it: use it "as a last resort", because overusing it "will result in excessive memory use". Put it on an element just before it animates and take it off after, not on everything in your stylesheet.
Making layout run twice
Layout normally runs once per frame, at the end. You can force it to run early by reading a layout value, like offsetWidth or getBoundingClientRect(), after changing a style. The browser has to do layout right there to give you a correct answer. web.dev calls this forced synchronous layout.
Do it in a loop and you get layout thrashing:
// Every read forces a fresh layout.
for (const p of paragraphs) {
p.style.width = `${box.offsetWidth}px`;
}
// Read once, then write. One layout, at the end of the frame.
const width = box.offsetWidth;
for (const p of paragraphs) {
p.style.width = `${width}px`;
}
The first version can run layout once per paragraph. The second runs it once in total. Group your reads, then your writes.
The number users feel
All of this shows up in one metric. Interaction to Next Paint, or INP, measures how long it takes from a tap, click or key press until the next frame is on screen. It covers the wait before your handler can run, the handler itself, and the time to render the result. It became a Core Web Vital on March 12, 2024, replacing First Input Delay, which only measured the wait before the very first interaction.
Under 200ms counts as good. Over 500ms counts as poor. The demo's busy second would score as poor on any click that landed in it.
The useful way to think about all of this is that the main thread is shared. Every millisecond your code holds it is a millisecond the browser can't spend drawing. The cheapest animation is the one that doesn't need the main thread at all.