Browser Rendering and Reflow Fundamentals
Most web performance advice is a list of commandments delivered without explanation. Minify your CSS. Defer your JavaScript. Animate with transforms, not top and left. Don't read an element's height in a loop. Each rule is correct, but memorizing rules is fragile — the moment a situation isn't on the list, you're guessing again.
All of those rules fall out of one thing: the browser's rendering pipeline, the assembly line that turns your HTML and CSS into pixels sixty times a second. Understand the pipeline — what each stage does, what each stage costs, and what forces the browser to run a stage again — and the commandments stop being commandments. They become obvious. This post is that mental model, kept practical: enough internals to reason with, none of the trivia.
The pipeline: from bytes to pixels in five stages
When a browser loads a page (and again, in part, every time something changes), the work flows through roughly five stages:
- Parse. HTML becomes the DOM — a tree of every element on the page. In parallel, CSS becomes the CSSOM — a structure of every rule. These two combine into the render tree: only the elements that will actually be displayed, each paired with its final computed styles. (Things like
display: noneelements and<head>contents don't make the cut.) - Layout (also called reflow). The browser walks the render tree and computes geometry: exactly where every box sits and how big it is. This is intrinsically global work — a paragraph getting one line taller pushes down everything after it, which can resize its container, which can rewrap a float two sections away. Geometry is a web of dependencies, and that's precisely what makes layout the expensive stage.
- Paint. With geometry settled, the browser fills in pixels: text, colors, borders, shadows, images. Paint happens on one or more layers — think of them as transparent sheets stacked on top of each other.
- Composite. The layers get assembled into the final frame, usually on the GPU. Compositing is cheap: sliding an already-painted layer around is the graphics card's favorite job.
- Repeat, on demand. After first render, the browser only redoes the stages that a change invalidates. This is the crucial part — the entire discipline of rendering performance is about invalidating as little of the pipeline as possible.
The cost hierarchy: layout > paint > composite
Here's the fact that organizes everything else. When you change something on a page, the browser re-enters the pipeline at the stage your change affects, and runs everything downstream of it:
| You change… | Pipeline runs | Cost | Examples |
|---|---|---|---|
| Geometry | Layout → Paint → Composite | Expensive | width, height, margin, padding, top/left, font-size, adding/removing DOM nodes |
| Appearance only | Paint → Composite | Moderate | color, background, box-shadow, visibility |
| Compositor-only properties | Composite | Cheap | transform, opacity |
This table is the reason for the animation commandment. Animate an element's left property and the browser recomputes layout for affected parts of the page, repaints, and recomposites — up to 60 times per second, on the main thread, competing with your JavaScript. Animate transform: translateX() instead and the browser just slides an already-painted layer across the screen on the GPU, often without involving the main thread at all. Same visual result; a couple of orders of magnitude difference in work. It's also why a hover effect implemented as transform: scale(1.05) stays silky on a page where the same effect via width stutters.
The practical rule: if something needs to move or fade smoothly, do it with transform and opacity. Nearly every UI animation — slides, zooms, fades, spinners — can be expressed in those two properties.
Reflow: what actually triggers it
"Reflow" is just the traditional name for the layout stage running again. It's triggered by anything that changes geometry:
- Adding, removing, or moving DOM elements (every widget that injects itself into your page does this)
- Changing box dimensions: width, height, padding, margin, borders
- Content changes that alter size — text edits, an image arriving without reserved dimensions
- Font changes, including a web font swapping in (this is why font swaps can cause layout shift)
- Resizing the window or rotating a phone
Reflow itself isn't a bug — it's how pages work; you can't render changing content without it. Reflows become a performance problem in two situations: when they're huge (invalidating most of a complex page) or when they're frequent (dozens per second during scrolling or animation). And there's one specific pattern that manufactures frequent reflows out of innocent-looking code, which deserves its own section.
Layout thrashing: the forced synchronous reflow
Browsers are lazy in the best way: when your JavaScript changes styles, the browser doesn't reflow immediately. It queues the change, planning to do one batch layout before the next frame. Change ten elements' widths in a loop, and you'd still pay for just one reflow. Efficient.
Unless you interrogate it. Properties like offsetHeight, offsetTop, clientWidth, scrollTop, and methods like getBoundingClientRect() ask a geometry question — and the browser can't answer from stale data if there are pending changes. It's forced to run layout right now, synchronously, to give you a correct number.
Now consider the innocent loop:
items.forEach(item => {
const h = item.offsetHeight; // READ — forces layout
item.style.height = (h * 2) + 'px'; // WRITE — invalidates layout
});
Read, write, read, write. Every read finds layout dirty from the previous write and forces a full synchronous reflow. A hundred items means a hundred reflows in one frame — this is layout thrashing, and it's the number one cause of the "this page freezes when I click the thing" bug. In Chrome DevTools' Performance panel it shows up as long purple Layout blocks with a warning: "Forced reflow is a likely performance bottleneck."
The fix is always the same shape: batch your reads, then batch your writes.
const heights = items.map(item => item.offsetHeight); // all reads
items.forEach((item, i) => {
item.style.height = (heights[i] * 2) + 'px'; // all writes
});
One layout for all the reads, one batched layout for all the writes. Same logic, two reflows instead of a hundred.
What this means during page load: render blocking
The pipeline also explains the loading commandments. The browser cannot paint anything until it has both the DOM and the CSSOM — rendering with half the styles would mean showing users a flash of unstyled chaos and then reflowing everything. So:
- CSS is render-blocking by definition. Every stylesheet in your
<head>must download and parse before the first pixel appears. This is why shipping 400 KB of page-builder CSS hurts even when it's cached-after-first-visit: first visits are the ones that form impressions. It's also the logic behind "critical CSS" — inline the few kilobytes needed for above-the-fold content, load the rest without blocking. - Classic
<script>tags block parsing itself. The parser must stop, fetch, and execute the script before continuing, because the script might rewrite the document out from under it. Hencedefer— which downloads in parallel and executes after parsing — being the correct default for nearly every script a WordPress site loads.
The pipeline model turns "minimize render-blocking resources" from an audit complaint into a sentence with a subject: you are making the first paint wait for things it doesn't need.
Seeing it yourself in ten minutes
Two DevTools exercises make all of this concrete:
- Paint flashing. In Chrome DevTools, open the Rendering panel (three-dot menu → More tools → Rendering) and enable "Paint flashing." Green rectangles flash over any region being repainted. Scroll your site, hover your menus, watch your carousel — you'll see exactly which widgets keep the pipeline busy. A page that's mostly calm with small green flashes is healthy; a page that strobes green on every scroll has paint work worth investigating.
- Record a Performance profile. Record a few seconds of interaction. Purple blocks are layout, green are paint. Look for purple blocks with the forced-reflow warning, and for "Long tasks" flagged red — those are the frames your users felt as jank.
The takeaways, now with reasons attached
- Animate
transformandopacity— they skip layout and paint entirely and ride the GPU. - Set dimensions on images and embeds — geometry known in advance means the arriving image triggers no reflow and no layout shift.
- Batch DOM reads and writes — interleaving them forces synchronous reflows; grouping them lets the browser do one.
- Keep CSS lean and scripts deferred — the first paint waits for the CSSOM, and parsing waits for classic scripts.
- Change classes, not inline styles in loops — one class toggle that changes many properties invalidates the pipeline once.
None of this requires becoming a browser engineer. It requires one picture: an assembly line where geometry is expensive, painting is moderate, and sliding finished layers around is nearly free — plus the knowledge that the browser batches work unless your code forces its hand. Hold that picture, and the next performance decision you face won't need a rule from a list. You'll be able to derive it.