Making the Browser Do Less -- CSS Rendering Performance Deep Dive
From fixing a janky progress bar to Layout Thrashing and modern CSS optimization
From fixing a janky progress bar to Layout Thrashing and modern CSS optimization
Had a janky progress bar on mobile. Switching from width to transform: scaleX() fixed it instantly.
It was a one-line change, but digging into why led me to realize I needed to understand the entire browser rendering pipeline.
// Before - reflow every frame
<div style={{ width: `${progress}%` }} />
// After - GPU-accelerated, no reflow
<div
className="w-full origin-left will-change-transform"
style={{ transform: `scaleX(${progress / 100})` }}
/>
The difference is clear in the Chrome DevTools Performance panel.
width version: Layout events repeat every frame, many dropped framestransform version: Only Composite runs, almost no frame dropsI wrote about why this happens in my browser rendering pipeline note. The gist is:
width change → Recalculate from Layout → Main thread blockingtransform change → Composite only → GPU handles itMobile has a much weaker main thread than desktop, so the same code produces a dramatically different experience.
The progress bar case was caused by picking the wrong CSS property. But there's a more common and harder-to-find problem in real-world code: Layout Thrashing.
Browsers normally batch style changes and process them all at once. But if you read a layout property right after changing a style, the browser is forced to calculate layout "right now."
element.style.width = '200px';
const height = element.offsetHeight; // forces Layout here
Once is fine. The problem is when this happens inside a loop.
// Bad: forced reflow on every iteration
elements.forEach(el => {
const height = el.offsetHeight; // read → forces Layout
el.style.height = height + 10 + 'px'; // write → next read forces Layout again
});
N elements means N layout calculations. 100 elements? 100 reflows.
// Good: batch reads first, then writes
const heights = elements.map(el => el.offsetHeight); // batch read
elements.forEach((el, i) => {
el.style.height = heights[i] + 10 + 'px'; // batch write
});
Separating reads from writes significantly reduces the number of layouts.
For extra safety, you can defer writes to the next frame with requestAnimationFrame.
const heights = elements.map(el => el.offsetHeight);
requestAnimationFrame(() => {
elements.forEach((el, i) => {
el.style.height = heights[i] + 10 + 'px';
});
});
In the DevTools Performance panel, if you see purple Layout blocks repeating at short intervals, suspect Layout Thrashing. The warning "Forced reflow is a likely performance bottleneck" confirms it.
Beyond choosing the right CSS properties and fixing JS patterns, modern CSS lets you tell the browser "you can handle this area like this."
Skips rendering entirely for elements outside the viewport.
.feed-item {
content-visibility: auto;
contain-intrinsic-size: auto 200px;
}
The browser skips Layout and Paint for .feed-item when it's off-screen.
It only renders when the element scrolls into the viewport.
contain-intrinsic-size tells the browser the expected size before rendering.
Without it, the scrollbar jumps around unpredictably.
This can dramatically reduce initial rendering time for long lists and feed-style UIs. Chrome team benchmarks have shown up to 7x reduction in rendering time.
The underlying property behind content-visibility.
It limits how far rendering changes inside an element can propagate outward.
.card {
contain: layout paint;
}
contain: layout → Internal layout changes don't affect the outsidecontain: paint → Internal painting doesn't bleed beyond element boundariescontain: strict → Isolates size + layout + style + paint entirely (use with caution)Effective for UIs with many independent regions: infinite scroll lists, card grids, dashboard widgets.
Caveat: contain: size makes the element ignore its children's size and can collapse to 0x0.
You must explicitly set dimensions, or the layout will break.
will-change tells the browser "this property is about to change" so it can optimize ahead of time.
.animating {
will-change: transform;
}
But overusing it backfires.
Elements with will-change get promoted to a separate compositor layer, which consumes GPU memory.
Applying will-change: transform to every element causes layer explosion and potential memory issues.
The rules are simple:
transform and opacity instead of width, height, top, left for animations?content-visibility: auto to long lists?will-change only on necessary elements, only when needed?contain to independent UI regions?