CSS rendering: layers, threads, and why transform beats width

frontendbrowsercssperformance

Animating width triggers Layout, Paint, and Composite. Three stages, every frame. Animating transform triggers only Composite. One stage, on the GPU. Same visual result. Completely different cost.

The rendering waterfall

The browser renders in stages. Each stage feeds the next. Triggering an early stage forces everything after it to re-run.

Style → Layout → Paint → Composite

Change width? The browser recalculates Layout, repaints pixels, re-composites layers. Three stages. On a page with 500 DOM nodes, that Layout step alone can take 10-50ms. Way over the 16ms budget for 60fps.

Change color? Layout stays the same. Repaint and re-composite. Two stages.

Change transform? Layout and Paint stay the same. Only re-composite. One stage. And it runs on the Compositor Thread, not the Main Thread. JavaScript can't block it. Heavy event handlers can't block it. Nothing on the Main Thread can touch it.


Which properties trigger what

TriggerPropertiesWhat happens
Layoutwidth, height, margin, padding, top, left, font-sizeRecalculates positions of surrounding elements. Expensive.
Paintcolor, background, border-color, box-shadow, outlineRedraws pixels. Positions stay the same.
Compositetransform, opacity, filterRe-composites layers on the GPU. Cheap.

For animations, always pick the Composite version:

Instead ofUse
left / top / margintransform: translateX/Y()
width / heighttransform: scale()
display: none/blockopacity: 0/1

Compositor layers

Not every element gets its own layer. Each layer costs GPU memory. A 500x500 element takes roughly 1MB. The browser promotes elements to independent Compositor layers only when it has a reason to.

What creates a layer:

  • 3D transforms. translateZ(0) and translate3d() trigger layer promotion immediately. 2D transforms like translateX(50px) usually don't, unless they're inside an active animation.
  • will-change: transform. Forces the browser to create a layer in advance. Use it on elements you know will animate. Remove it after. Keeping layers alive wastes memory.
  • position: fixed. Fixed elements get their own layer for smoother scrolling.
  • Active CSS animations. Even 2D transforms temporarily get a layer during animation. The browser promotes them for the duration, then demotes them when it ends.

What doesn't create a layer:

  • Hover states. Hover itself has nothing to do with layers. A color change on hover stays on the Main Thread. Only the animation triggered by hover might create one.
  • Static transform. transform: translateX(50px) without animation or will-change is just a layout hint. No layer promotion.

What this looks like in practice

A sidebar that slides in. Two ways to build it.

/* Animating width: Layout reflow every frame */
.sidebar {
  width: 0;
  transition: width 0.3s;
}
.sidebar.open {
  width: 240px;
}

DevTools shows: Layout (12ms) → Paint (3ms) → Composite (0.5ms). Every frame. On a page with a data table, those Layout events cascade. The table recalculates column widths, rows reflow, the whole page shifts. Total frame time: 40ms. That's 25fps. Users see jank.

/* Animating transform: Compositor only */
.sidebar {
  width: 240px;
  transform: translateX(-100%);
  transition: transform 0.3s;
}
.sidebar.open {
  transform: translateX(0);
}

DevTools shows: Composite (0.5ms). That's it. The sidebar has its own layer during the transition. The Main Thread never gets involved. 60fps on a budget Android phone.

The sidebar is always 240px wide. It just starts off-screen and slides in. Same visual result. No reflow.


The will-change trap

Developers discovered will-change and slapped it on everything.

/* Don't do this */
* { will-change: transform, opacity; }

Each will-change creates a layer. Each layer costs memory. A page with 200 elements promoted to layers can eat 200MB of GPU memory. On mobile, the browser runs out and starts thrashing. The "optimization" makes things worse.

Use will-change like a scalpel:

/* Add it right before animation */
.card:hover {
  will-change: transform;
}
.card:active {
  transform: scale(0.98);
  transition: transform 0.1s;
}

Or better: let CSS animations handle it. The browser auto-promotes elements during transition and animation. You only need will-change when you want the layer created before the animation starts, to avoid a first-frame hitch.


Quick checklist

  1. Animate only transform and opacity. These are the only properties guaranteed to run on the Compositor Thread.
  2. Use will-change on one or two elements, not globally. Remove it after animation ends.
  3. Open DevTools Performance tab. If you see green "Paint" bars during animation, you're not on the Compositor Thread.
  4. Test on real devices. Compositor animations look the same everywhere. Layout animations fall apart on slow hardware.

References