⚡ Web performance

Smooth web animations: compositor, will-change and forced reflow

Which CSS properties to animate without jank, how to avoid layout thrashing by batching DOM reads and writes, and how to verify it with DevTools.

A janky animation immediately makes a site feel slow, even if the page loaded quickly. To look smooth, an animation must produce a new frame on every screen refresh: on a 60 Hz display that means about 16 milliseconds per frame, and part of that time is needed by the browser for its own work. If the code or the CSS need longer, the frame is dropped and the eye notices.

Understanding why this happens requires knowing the stages the browser goes through to produce an image.

The rendering stages

For each frame the browser may have to run, in order:

  1. JavaScript: event handlers, timers, animation callbacks;
  2. style: computing the CSS rules applied to each element;
  3. layout: the position and size of every element;
  4. paint: drawing the pixels of text, colours, borders and shadows into one or more layers;
  5. composite: assembling the layers into the final image, often with help from the GPU.

The key point is that not every change triggers every stage. Changing width or top requires redoing layout, then paint, then compositing. Changing background-color skips layout but requires paint. Changing transform or opacity on an element that has its own layer requires only compositing, which can happen on a thread separate from the main thread.

Animating the right properties

Animated property Stages involved Smoothness
transform, opacity Compositing only Excellent
color, background-color, box-shadow Paint and compositing Depends on the area
width, height, top, left, margin Layout, paint, compositing Often poor

Almost all common effects can be achieved with the properties in the first row:

  • a movement uses transform: translate() instead of left or top;
  • an enlargement uses transform: scale() instead of width and height;
  • a fade uses opacity;
  • a panel that opens can start from transform: translateY(-100%) instead of animating its height.

Animating transform also helps CLS: movements produced by transforms are not counted as layout shifts.

will-change and layers

To animate an element through compositing alone, the browser must put it on its own layer. It often does so by itself when the animation starts, but creating the layer can cost a slow frame right at the beginning. The will-change property warns it in advance:

.drawer {
  will-change: transform;
}

It is a tool to use sparingly. Each layer takes GPU memory, and applying will-change to dozens of elements, or worse to *, can make performance worse rather than better. Reserve it for elements that really animate, and often, or add it just before the animation and remove it afterwards.

Layout thrashing

The most insidious problem is not in CSS but in JavaScript. Normally the browser batches changes and recalculates layout once before painting. But if the code reads a geometric property, such as offsetHeight, getBoundingClientRect() or scrollTop, right after changing something, the browser is forced to recalculate layout at that moment in order to return a correct value. This is a forced reflow.

If it happens inside a loop, layout is recalculated on every iteration:

// Avoid: interleaved reads and writes
for (const box of boxes) {
  box.style.height = (box.offsetWidth / 2) + 'px';
}

The fix is to separate reads from writes:

// Better: all reads first, then all writes
const widths = boxes.map(b => b.offsetWidth);
boxes.forEach((b, i) => { b.style.height = (widths[i] / 2) + 'px'; });

With many elements the difference is enormous: from hundreds of recalculations down to one.

requestAnimationFrame

For JavaScript-driven animations, requestAnimationFrame runs code just before the next frame, in sync with the screen. It is preferable to setTimeout or setInterval, which can fire at any point in the frame and cause jank. The browser also suspends the callbacks when the tab is not visible, saving battery.

For many effects, though, CSS is still the best choice: transitions and animations declared in the stylesheet on transform and opacity can run on the compositor thread even when the main thread is busy. The Web Animations API offers the same advantage with JavaScript control.

Verifying with DevTools

Chrome DevTools offers specific tools in the Rendering panel (from the command menu):

  • Paint flashing highlights repainted areas in green: if the whole page flashes during an animation, something is wrong;
  • Layout Shift Regions shows the areas that move;
  • Frame Rendering Stats displays dropped frames in real time.

In the Performance panel, forced reflows are flagged with a warning on layout entries, and the detail pane shows the line of code that caused them. It is the quickest way to find the offending loop.

In short

  • A smooth animation must fit in a frame's budget, about 16 ms at 60 Hz.
  • Animate transform and opacity, which require only compositing.
  • Use will-change only on elements that really animate.
  • Avoid layout thrashing by separating DOM reads and writes.
  • Verify with Paint flashing and the forced reflow warnings in the Performance panel.
MM
Marco Marzaduri

Marco Marzaduri works in cybersecurity, digital forensics and web development. A computer science graduate, he combines rigorous technical analysis with attention to the people who use technology.

Read also