Concept
CSS performance has two largely independent dimensions: rendering performance (how expensive is it for the browser to compute and paint styles, especially during interaction/animation) and delivery performance (how much render-blocking CSS does the browser have to download and parse before it can show anything). Both matter, and they're solved differently.
The rendering pipeline: style → layout → paint → composite
Every frame the browser draws goes through up to four stages, and which CSS properties you change determines how many of them re-run:
- Style, recalculating which CSS rules apply to which elements (always happens on any style change).
- Layout (reflow), recalculating geometry: position and size of every affected element. Triggered by properties like
width,height,top,margin,font-size. - Paint, filling in pixels: color, shadows, borders, text. Triggered by properties like
background-color,box-shadow,color, layout-unaffecting but still requires repainting. - Composite, combining already-painted layers together on the GPU.
transformandopacitychanges can skip both style-triggered layout and paint, going straight to a cheap re-composite of existing layers.
This is the generalized version of the "animate transform/opacity, not width/top" rule from the Transitions and Animations topics, the underlying reason is exactly this pipeline: transform/opacity are the only properties that can be handled purely at the composite stage, skipping the two most expensive stages entirely.
Layout thrashing, the classic JS-caused CSS performance bug
// Wrong: forces a synchronous layout recalculation on EVERY iteration
elements.forEach((el) => {
el.style.width = el.offsetWidth + 10 + "px"; // READ (offsetWidth) then WRITE (style.width), interleaved
});// Right: batch all reads, then all writes, one layout recalculation total, not N
const widths = elements.map((el) => el.offsetWidth); // all READS first
elements.forEach((el, i) => { el.style.width = widths[i] + 10 + "px"; }); // all WRITES afterReading a layout-dependent property (offsetWidth, getBoundingClientRect(), scrollHeight, computed styles) forces the browser to synchronously flush any pending layout-affecting changes to answer accurately, interleaving reads and writes in a loop forces a full synchronous layout recalculation on every iteration, not once. This is "layout thrashing," a purely JS-caused CSS performance problem, and the fix is always the same: batch all DOM reads together, then all DOM writes together.
will-change, a real tool with a real cost
.card {
will-change: transform; /* hints the browser to promote this to its own compositor layer proactively */
}will-change tells the browser to prepare an optimization (typically, promoting the element to its own GPU compositor layer) before the animation starts, avoiding a layer-creation cost at the exact moment the animation begins. This genuinely helps for elements about to be animated, but each promoted layer consumes real GPU memory, and applying will-change broadly/permanently (rather than toggling it on shortly before an animation and removing it after) can itself cause a performance regression from excessive layer memory usage. It's a scalpel, not a blanket "make things faster" flag.
Selector performance, real but usually not the bottleneck
/* Historically taught as "slow" due to right-to-left selector matching, but modern
browser engines have optimized this substantially, rarely the actual bottleneck today */
.container div span a { }