Kodokon kodokon.com

The rendering pipeline: reflow, repaint, compositing

Understand the four phases of browser rendering so you know exactly which CSS properties are expensive and how will-change works under the hood.

11 min · 3 questions

Open this lesson in Kodokon

The browser turns your CSS into pixels through a four-phase pipeline: Style (computing values), Layout (geometry, also known as reflow), Paint (rasterizing into pixels, the repaint) and Composite (assembling layers on the GPU). The golden rule: the earlier the phase you touch, the bigger the bill, because every subsequent phase gets replayed. Changing width or top triggers layout + paint + composite. Changing color or box-shadow skips layout but repaints. Only transform and opacity can be handled by the compositor thread alone: the animation stays smooth even when the main thread is blocked by JavaScript.

CSS
.slide-bad {
  position: absolute;
  transition: left 0.3s ease;
}
.slide-bad:hover { left: 120px; }

.slide-good {
  transition: transform 0.3s ease;
}
.slide-good:hover { transform: translateX(120px); }
Two visually identical animations: the first forces a reflow on every frame, the second stays on the compositor.

The browser is lazy: it batches your style writes and only recomputes layout at the next refresh. But some JavaScript reads - offsetHeight, getBoundingClientRect(), scrollTop, getComputedStyle() on a geometric property - demand up-to-date values. If a write is pending, the browser must recompute immediately and synchronously: that is a forced reflow. Alternating reads and writes inside a loop produces layout thrashing: one full reflow per iteration instead of a single one for the whole batch.

JAVASCRIPT
const items = document.querySelectorAll('.item');

items.forEach((item) => {
  const h = item.offsetHeight;
  item.style.height = h * 2 + 'px';
});

const heights = [...items].map((i) => i.offsetHeight);
items.forEach((item, index) => {
  item.style.height = heights[index] * 2 + 'px';
});
Version 1: one forced reflow per element. Version 2: all reads first, then all writes - a single reflow.

Compositing slices the page into layers that are rasterized independently and assembled by the GPU. will-change: transform asks for the element to be promoted to its own layer before the animation, avoiding the costly rasterization on the first frame. A spec subtlety that is often overlooked: will-change must produce the same side effects as the property it announces. So will-change: transform creates a stacking context and turns the element into a containing block for its position: fixed descendants - exactly like a real transform. A simple performance hint can therefore break your stacking order or your fixed elements.

JAVASCRIPT
const panel = document.querySelector('.panel');

panel.addEventListener('pointerenter', () => {
  panel.style.willChange = 'transform';
});

panel.addEventListener('transitionend', () => {
  panel.style.willChange = 'auto';
});
The pattern recommended by the specification: promote just before the animation, release just after.

Knowledge check

Make sure you remember the key points of this lesson.

  1. Which animation can be handled entirely by the compositor thread, with no reflow or repaint?
    • height from 0 to 200px
    • margin-left from 0 to 100px
    • transform: translateX(100px)
    • box-shadow on hover
  2. Why is reading offsetHeight inside a loop that also modifies styles so expensive?
    • Each read forces a synchronous reflow if a write is pending
    • offsetHeight triggers a GPU repaint on every access
    • The property is never cached by the JavaScript engine
  3. Which side effect does will-change: transform share with a real transform?
    • It disables the element's CSS transitions
    • It creates a stacking context and a containing block for position: fixed
    • It forces the element into display: block
    • It doubles the rasterization resolution