The Pixel Pipeline
Every visual change on screen passes through up to five stages. The browser can skip later stages when only early ones are affected, so understanding the pipeline tells you exactly how expensive each CSS property change really is.
Layout Thrashing (Forced Synchronous Reflow)
The browser lazily batches layout work. Reading a geometry property (e.g. offsetWidth) after a DOM write forces the browser to immediately flush pending layout so it can return an accurate value. Do this in a loop and you trigger dozens of layout recalculations per frame — the classic "layout thrashing" pattern.
// Each iteration: write → read → layout recalc → write → read → …
const boxes = document.querySelectorAll('.box');
for (const box of boxes) {
// WRITE: schedule a layout-dirtying mutation
box.style.width = box.offsetWidth * 2 + 'px'; // READ forces immediate reflow!
// The browser must recalculate layout RIGHT NOW to answer offsetWidth.
// With 100 boxes that is 100 forced reflows per frame.
}useEffect can still trigger it.Cheap vs Expensive CSS Properties
Not all CSS properties cost the same. Properties that trigger layout are the most expensive; properties that only trigger compositing are essentially free on the GPU.
| Property | Pipeline stages triggered |
|---|---|
| width, height, margin, padding, top, left | Layout → Paint → Composite (most expensive) |
| background-color, color, box-shadow, border | Paint → Composite (skips layout) |
| transform (translate, scale, rotate) | Composite only — runs entirely on GPU |
| opacity | Composite only — runs entirely on GPU |
| filter (blur, brightness…) | Paint → Composite (promoted layer) |
| will-change: transform | Promotes to own compositing layer in advance |
will-change: transform to every element forces the browser to allocate a separate GPU texture per element. On memory-constrained devices this causes more jank than it prevents. Apply it only to elements that are actively animating and remove it once the animation ends.requestAnimationFrame, Debounce & Throttle
Visual updates must be synchronised with the browser's paint cycle. Scroll and resize handlers fire far more often than the display refreshes — batching them avoids wasted work.
// ── rAF for smooth animation ──────────────────────────────────────────
function animate(timestamp) {
const progress = (timestamp % 2000) / 2000; // 2-second loop
element.style.transform = `translateX(${progress * 300}px)`;
requestAnimationFrame(animate); // schedule next frame
}
requestAnimationFrame(animate);
// ── Throttle scroll with rAF (runs at most once per frame) ────────────
let rafPending = false;
window.addEventListener('scroll', () => {
if (rafPending) return; // already scheduled — skip
rafPending = true;
requestAnimationFrame(() => {
updateScrollUI(); // safe: runs at next paint
rafPending = false;
});
});
// ── Debounce resize (run only after resizing stops) ───────────────────
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
recalculateLayout(); // fires 150 ms after last resize event
}, 150);
});setInterval fires on a wall-clock timer that is misaligned with the display refresh. The result is micro-stutters and wasted frames. requestAnimationFrame is called by the browser exactly when it is about to paint, guaranteeing alignment.The 16.67 ms Frame Budget
At 60 fps the browser must produce a new frame every 16.67 ms. Exceed that budget and the frame is dropped — users see a stutter. At 120 fps (ProMotion / high-refresh displays) the budget halves to 8.33 ms.
content-visibility & CSS containment
Off-screen content still costs layout and paint time. The content-visibility property lets the browser skip rendering work for elements that are not yet visible, dramatically cutting initial render cost on long pages.
/* Skip rendering of off-screen sections until they scroll near the viewport */
.card {
content-visibility: auto;
/* Reserve space so the scrollbar does not jump as cards render */
contain-intrinsic-size: auto 240px;
}
/* Isolate a widget so its internal changes never invalidate outside layout */
.widget {
contain: layout paint style;
}content-visibility: auto can cut initial render time by half or more — the browser only lays out and paints what is near the viewport. Always pair it with contain-intrinsic-size to avoid scroll-position jumps.Long Tasks & INP (Interaction to Next Paint)
Any task that occupies the main thread for more than 50 ms is a "long task" — it blocks input handling and hurts INP, the Core Web Vital that measures interaction responsiveness. Break long work into chunks and yield to the main thread between them.
// Yield to the main thread so the browser can paint and handle input
function yieldToMain() {
if ("scheduler" in window && "yield" in scheduler) {
return scheduler.yield(); // modern API — highest priority continuation
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function processInChunks(items) {
for (let i = 0; i < items.length; i++) {
doExpensiveWork(items[i]);
// Every 50 items, hand control back so input stays responsive
if (i % 50 === 0) {
await yieldToMain();
}
}
}