CSS and Rendering Performance
JavaScript takes most of the blame for a janky page, but a large share of real-world jank actually traces back to CSS and what the browser has to do in response to it. This lesson is about what happens between a style change and pixels landing on screen, and the small set of properties and APIs that let you control that on purpose.
Four stages, and which ones a given change skips
Every frame the browser paints goes through some or all of four stages: Style (which CSS rules apply to which elements), Layout (the size and position of every affected element, also called reflow), Paint (filling in pixels: text, colors, shadows), and Composite (combining painted layers into the final image, on the GPU).
| Change | Layout | Paint | Composite |
|---|---|---|---|
width, height, top, left, margin |
yes | yes | yes |
color, background, box-shadow |
no | yes | yes |
transform, opacity |
no | no | yes |
The expensive path is the top row. A layout-affecting property forces the browser to redo geometry for that element, sometimes its ancestors and siblings too, then repaint, then recomposite. transform and opacity are the outlier: they can be handled entirely on the GPU's compositor thread, skipping Layout and Paint completely. That is not a small footnote. It is the whole reason those two properties are the default choice for animating anything on screen.
/* Forces reflow every frame */
.card { transition: top 0.3s, left 0.3s; }
.card.moved { top: 100px; left: 50px; }
/* Same visual result, compositor-only */
.card { transition: transform 0.3s; }
.card.moved { transform: translate(50px, 100px); }Remember:
transformandopacityare the only two properties that can skip layout and paint entirely and go straight to the GPU.
will-change is a promise, and promises aren't free
will-change tells the browser to promote an element onto its own compositor layer before the change happens, so the layer does not have to be created mid-animation.
.card { will-change: transform; }The trap is treating this as a blanket performance boost instead of a targeted one. Every promoted layer eats GPU memory, and leaving will-change on permanently, instead of toggling it on right before an animation and off right after, can split the page into so many layers that it slows things down instead of speeding them up.
element.style.willChange = "transform";
element.addEventListener("transitionend", () => {
element.style.willChange = "auto";
}, { once: true });Remember:
will-changeis a promise you make to the browser ahead of time, not a permanent setting. Turn it off once the animation ends.
Layout thrashing: reading and writing in the wrong order
This happens when JavaScript interleaves reads and writes of layout-dependent properties inside a loop, forcing the browser to synchronously recompute layout on every single iteration instead of batching the whole thing into one recalculation per frame.
// Bad: read, write, read, write — forces a synchronous layout flush every time
boxes.forEach((box) => {
const width = box.offsetWidth; // read
box.style.width = `${width * 2}px`; // write invalidates the layout cache
});
// Good: batch all reads first, then all writes
const widths = boxes.map((box) => box.offsetWidth);
boxes.forEach((box, i) => { box.style.width = `${widths[i] * 2}px`; });offsetWidth, offsetHeight, getBoundingClientRect(), scrollTop, and getComputedStyle() are the usual culprits on the read side, since each one forces the browser to guarantee an up-to-date layout before it can answer. Open Chrome DevTools' Performance tab, record the interaction, and look for a tight, repeated sequence of purple "Layout" blocks, or check the summary for a "Forced reflow" warning, DevTools flags this pattern by name.
Remember: batch every read before any write. Interleaving them forces one layout recalculation per loop iteration instead of one for the whole loop.
contain and content-visibility: telling the browser what it can skip
contain declares that an element's internals are isolated from the rest of the page, so a change inside it cannot ripple outward, and the browser does not need to recheck anything outside its boundary.
.list-item { contain: content; } /* layout + paint + style */
.chart-widget { contain: strict; } /* content + a fixed size, the strongest guarantee */For a dashboard built from many independent widgets, wrapping each in contain: content means updating one widget's DOM never forces the browser to re-check layout for its siblings.
content-visibility: auto builds on the same idea for anything currently off-screen:
.long-article section {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* a placeholder size, used before first real render */
}Sections outside the viewport skip rendering work entirely until they scroll into view, no JavaScript required. It is the same underlying goal as list virtualization: do the least work possible for content the user cannot currently see, just handled natively by the browser instead of hand-rolled.
Remember:
containtells the browser "nothing in here affects anything out there," so it can stop checking.
Getting out of the way of first paint
By default, <link rel="stylesheet"> blocks rendering: nothing paints until every linked stylesheet has downloaded and parsed, even if the HTML itself was ready instantly.
Critical CSS inlines just enough CSS for the above-the-fold view directly in <head>, and defers the rest:
<head>
<style>
.header { ... }
.hero { ... }
</style>
<link rel="preload" href="/styles/main.css" as="style" onload="this.onload=null;this.rel='stylesheet'" />
<noscript><link rel="stylesheet" href="/styles/main.css" /></noscript>
</head>Tools like critical or critters automate pulling out that above-the-fold slice at build time. Next.js does the equivalent automatically for CSS Modules in production. Two other render-blocking culprits worth naming in the same breath: a synchronous <script> in <head> blocks HTML parsing entirely until it downloads and runs (defer and async both fix that for non-critical scripts), and an unstyled web font either flashes invisible text or shifts the layout when the real font swaps in, which font-display: swap fixes by showing the fallback font immediately.
@font-face {
font-family: "Inter";
src: url("/fonts/inter.woff2") format("woff2");
font-display: swap;
}Remember: a stylesheet blocks rendering by default. Inline the above-the-fold CSS and defer the rest to get pixels on screen sooner.
The habit worth building
What an interviewer is actually checking for, more than any individual fact above, is whether you connect a specific property to a specific pipeline stage to a specific fix. "This animates top, which forces layout on every frame, so switch it to transform and it becomes compositor-only" is the shape of a senior answer. Reciting the four stage names without wiring them to an actual property is not.