Browser Rendering Pipeline Explained: Reflow, Repaint, Compositing

Imad Attif, Sr. Frontend Engineer
18 min read
May 6, 2026
You've probably heard performance advice like:
- "Animate
transform, notmargin." - "Don't read
offsetHeightinside a loop." - "Batch your DOM updates."
These sound like arbitrary rules to memorize. They aren't. They all come from one idea: the browser rendering pipeline, the sequence of steps a browser goes through to turn your HTML, CSS, and JavaScript into pixels on the screen.
Once you understand the pipeline, you don't need to memorize the rules anymore. You can derive them yourself.
This post explains the pipeline from scratch: what each step does, what reflow, repaint, and compositing mean, and how the same visual change can be expensive or nearly free depending on how you write it. Every claim comes with code you can try and a way to verify it in DevTools.
Here's the cheat sheet up front. The rest of the post explains why it's true:
- Geometry properties (
width,margin,top,font-size) run the whole pipeline: style → layout → paint → composite. Most expensive. - Appearance properties (
color,background,box-shadow) skip layout: style → paint → composite. Cheaper. transformandopacityskip layout and paint: style → composite. Nearly free.
Fewer steps per frame means a smoother page. Most rendering-performance work is about moving your changes down this list.
How the browser builds a page
Before drawing anything, the browser turns your code into three tree structures:
- The DOM (Document Object Model): your HTML, parsed into a tree of objects. Every tag becomes a node.
- The CSSOM (CSS Object Model): all your CSS rules, parsed into a tree, with a record of which elements each rule applies to.
- The render tree: the merge of the two. Every visible element, paired with its final, computed styles. (Elements with
display: nonearen't in it.)
The render tree is the input to the pipeline. Now the browser knows what to draw. The pipeline decides where and how.
What is the browser rendering pipeline?
The pipeline is five steps. They run for the initial render, and again, in whole or in part, every time something on the page changes:
JavaScript → Style → Layout → Paint → Composite
Step by step:
- JavaScript. Something initiates a change: your code adds an element, toggles a class, or updates a style. (CSS animations can also start changes without JavaScript.)
- Style. The browser figures out which CSS rules now apply to the affected elements and recomputes their styles.
- Layout. The browser calculates each element's geometry (its exact position and size) and how the change shifts everything around it. This step is also called reflow.
- Paint. The browser fills in actual pixels: text, colors, borders, shadows, images. The result is one or more bitmaps (images in memory).
- Composite. The page is painted onto separate layers (think of them as transparent sheets stacked on top of each other). This step combines the layers, in the right order, into the final frame you see.
Why some steps are expensive and others are cheap
The key detail is where each step runs:
- Style, layout, and paint run on the main thread, the same thread that runs your JavaScript, your framework, and your event handlers. Work here competes with everything else your page does.
- Compositing runs on a separate compositor thread, helped by the GPU. Work here is nearly free. It doesn't block your JavaScript, and it isn't blocked by your JavaScript either.
Now add the time budget. To feel smooth, a page needs 60 frames per second, which gives you about 16 milliseconds per frame. If JavaScript + style + layout + paint take longer than that, the browser drops the frame, and the user sees stutter, which developers call jank.
One more piece of good news: the browser skips steps it doesn't need. Change an element's background-color and geometry is unaffected, so layout is skipped. Change its transform and even paint is skipped. Which steps each CSS property requires is exactly what the cheat sheet at the top summarizes.
That's the entire model. Everything below is applying it.
What is reflow, and what triggers it?
Reflow is the layout step re-running: the browser recalculating positions and sizes. It's the most expensive step, for two reasons:
- It runs on the main thread, so it blocks everything else.
- It cascades. Elements in normal document flow push each other around, so making one element taller can move its siblings, its parent, and everything below it. One small change can force geometry calculations for hundreds of elements.
In practice, reflow is triggered by:
- Adding or removing DOM nodes. Every insertion and removal triggers reflow: a "load more" button appending cards, a chat window receiving a message, a framework mounting components.
- Changing any geometry-affecting property:
width,height,margin,padding,top,left,font-size,display, and similar. - Resizing the window or rotating the device.
- CSS arriving late during initial load. The page lays out once, the stylesheet arrives, and the browser re-lays-out the entire document.
- Reading a layout value at the wrong time. This is the sneakiest trigger, and worth its own section.
Layout thrashing: the classic reflow mistake
The browser tries to be smart about reflow. When your JavaScript changes a style, it doesn't recalculate layout immediately. Instead, it marks layout as "dirty" and plans to do all the work once, right before the next frame.
But you can force its hand. Properties like offsetWidth, offsetHeight, and methods like getBoundingClientRect() report an element's current geometry. If layout is dirty when you read one, the browser has no choice: it must run layout right now to give you a correct answer. This is called a forced synchronous reflow.
Alternate reads and writes in a loop, and you force it over and over. This is layout thrashing:
1// ❌ Layout thrashing: read → write → read → write…2function resizeAllToMatch(elements, ratio) {3 for (const el of elements) {4 const width = el.offsetWidth; // READ: forces reflow,5 // because the previous6 // write made layout dirty7 el.style.width = width * ratio + 'px'; // WRITE: dirties layout again8 }9}
With 100 elements, that's 100 full layout recalculations in one function call.
The fix is simple: don't interleave. Do all the reads first, then all the writes:
1// ✅ Batched: two reflows total, no matter how many elements.2function resizeAllToMatch(elements, ratio) {3 const widths = elements.map((el) => el.offsetWidth); // all READS45 elements.forEach((el, i) => { // all WRITES6 el.style.width = widths[i] * ratio + 'px';7 });8}
Same result, same amount of code, but two reflows instead of a hundred. For work that repeats (like animations driven by JavaScript), put the writes inside requestAnimationFrame, which schedules them to run right before the browser renders the next frame.
Limiting how far reflow spreads
Since reflow cascades through elements that can push each other around, you can contain it by breaking that chain:
position: absoluteorfixedtakes an element out of normal flow. Its size and position no longer affect its siblings, so its changes reflow only itself.contain: layout(or the broadercontent-visibility: auto) tells the browser: "this element's insides can't affect the outside." Layout changes within it stay within it.
Both are useful for widgets that update constantly (a ticker, a timer, a live badge) sitting inside an otherwise static page.
What is repaint, and how is it different from reflow?
Repaint is the paint step re-running: pixels being redrawn without geometry changing. Change a background-color, a text color, or a box-shadow, and nothing moves, so the browser skips layout and just redraws the affected area.
The relationship between the two:
- Reflow always causes a repaint. New positions mean pixels must be redrawn. So reflow is the more expensive event: you pay for layout and paint.
- Repaint can happen without reflow, and it's cheaper, but not free. Painting large areas, or expensive effects like big blurred shadows, can still blow the 16ms frame budget on their own.
So the cost ranking is: reflow > repaint > composite. Which raises the obvious question: can you change what's on screen while skipping layout and paint?
Compositing: the fast path
Yes. That's what the composite step is for.
Remember that the browser paints the page onto separate layers, like transparent sheets. The compositor thread stacks these sheets to produce each frame. And two kinds of change can be applied to a whole sheet at stacking time, without redrawing anything on it:
- Moving, scaling, or rotating the sheet → the
transformproperty - Making the sheet more or less transparent → the
opacityproperty
These are the compositor-only properties. Animating them skips layout and skips paint. The compositor just repositions or fades an already-painted bitmap. And because the compositor thread is independent, these animations stay smooth even while the main thread is busy running JavaScript.
Example: one line of CSS, completely different cost
Both animations below slide a panel down 300 pixels. Visually, they're identical. Inside the pipeline, they couldn't be more different:
1/* ❌ margin-top is a geometry property.2 Every frame: Style → Layout → Paint → Composite.3 The main thread recalculates positions ~60 times per second. */4@keyframes slide-down-bad {5 from { margin-top: 0; }6 to { margin-top: 300px; }7}89/* ✅ transform is compositor-only.10 Every frame: Composite. Layout and paint never run. */11@keyframes slide-down-good {12 from { transform: translateY(0); }13 to { transform: translateY(300px); }14}
Animate 2,000 elements with the margin-top version and the CPU saturates and frames drop. Try it with CPU throttling in DevTools and it becomes a slideshow. The translateY version holds a steady frame rate with the main thread nearly idle.
The rule of thumb: express movement as transform (translate, scale, rotate) and fades as opacity. If a visual change can be restated in those two properties, it moves from the most expensive tier of the cheat sheet to the cheapest.
This rule explains patterns you've already seen in the wild. Virtualized lists (the technique behind smooth infinite feeds) position their rows with transform: translateY(...) instead of top or margins, so scrolling never touches layout. Off-canvas menus slide in with transform: translateX(...) for the same reason.
Common substitutions
- Instead of animating
top,left, ormargin, animatetransform: translate(). - Instead of animating
widthorheightfor grow/shrink effects, animatetransform: scale(). - Instead of toggling
display: nonefor fades, animateopacity(paired withvisibilityfor accessibility).
Layers and will-change: helping the browser, carefully
Compositor-only animations work because the animated element sits on its own layer, meaning its own separately-painted sheet. Browsers promote elements to their own layer automatically in most cases: when a transform animation starts, for <video>, for 3D transforms.
Sometimes the promotion happens a frame too late and the animation's first moments stutter. You can ask for promotion ahead of time:
1.drawer {2 will-change: transform; /* promote to a layer before the3 animation starts */4}
Here's the catch, and it's important: every layer costs GPU memory, roughly half a megabyte or more per layer depending on its size. It's tempting to conclude "layers make things fast, so promote everything." That backfires badly:
- Promote every row of a long list and you can consume 100–200 MB of GPU memory.
- GPU memory is shared with the entire operating system, not just your page. Exhaust it and the whole device starts struggling.
- Mobile devices, with small GPUs and most of your traffic, hit this wall first.
Rules of thumb:
- Promote only the few elements that actually animate often: a drawer, a carousel, a dragged card.
- Remove
will-changewhen the animation ends (toggle it with a class) rather than leaving it on forever. - Never apply
will-changein a broad selector like*orli.
Batching DOM changes
The pipeline model also explains the rules for building DOM efficiently. Every insertion into the live page triggers reflow, so the strategy is: assemble everything off-page, then attach once.
The tool for this is DocumentFragment: a lightweight container that exists only in memory. It's not part of the page, so changing it triggers nothing. Reflow happens once, when you append the finished fragment:
1// ❌ 100 live insertions → 100 reflows2for (const item of items) {3 const li = document.createElement('li');4 li.textContent = item.name;5 list.appendChild(li); // touches the live page every time6}78// ✅ Build in memory, attach once → 1 reflow9const fragment = document.createDocumentFragment();10for (const item of items) {11 const li = document.createElement('li');12 li.textContent = item.name;13 fragment.appendChild(li); // in memory: costs nothing14}15list.appendChild(fragment); // the only live-page change
Two related rules, same reasoning:
- On hot paths, prefer element objects over HTML strings.
innerHTMLandinsertAdjacentHTMLtake a raw string, so the browser must run its HTML parser on every call, which adds parsing cost on top of the reflow.createElement+appendChildskip the parser.innerHTMLis fine for one-time setup; avoid it inside loops or frequent updates. <template>is a reusable fragment. Its.contentproperty is aDocumentFragment. Define your markup once in HTML, thencloneNode(true)to stamp out copies cheaply.
(If you use React, Vue, or similar: the framework batches DOM updates for you, so you rarely write this code by hand. But the framework pays the same pipeline costs, which is why enormous lists are slow in every framework, and why virtualization exists.)
How to see all of this in DevTools
Don't take this post's word for anything: the pipeline is directly observable in Chrome DevTools.
- Performance panel. Click record, interact with your page, stop. In the flame chart, purple blocks are style/layout (reflow) and green blocks are paint. A forced synchronous reflow appears as a purple
Layoutblock inside one of your JavaScript calls, marked with a red triangle. DevTools even points to the line of code that caused it. - CPU throttling. In the Performance panel settings, slow the CPU 4×–6× to simulate a mid-range phone. Layout-heavy animations that seemed fine on your machine fall apart instantly; compositor-only ones don't.
- Paint flashing. Open the Rendering tab and enable "Paint flashing." Areas being repainted flash green. A
transformanimation should produce no flashing after its first frame. If the screen flashes green while you scroll, something is forcing repaints. - Layer borders / Layers panel. Also in the Rendering tab, "Layer borders" outlines every composited layer, and the Layers panel shows each one's memory cost, the quickest way to catch accidental over-promotion.
A habit worth adopting: whenever you write an animation or a loop that touches the DOM, record five seconds of it with 4× CPU throttling. If the frame rate holds, ship it.
FAQ
What's the difference between reflow and repaint? Reflow recalculates geometry (positions and sizes); repaint redraws pixels. Reflow always causes a repaint, so it's the more expensive of the two. A repaint alone (e.g. changing background-color) skips the layout step.
What CSS properties trigger reflow? Anything affecting geometry: width, height, margin, padding, top, left, font-size, border-width, display, and similar. Also DOM insertion/removal and reading properties like offsetHeight while layout is dirty.
Why are transform and opacity fast? They're applied at the composite step, on a separate thread, to an already-painted layer. The browser just repositions or fades an existing bitmap: no layout, no paint, and no waiting on the main thread.
Should I use will-change everywhere? No. Each promoted layer costs GPU memory, and over-promoting can make a page slower, especially on mobile. Use it on the few elements that animate frequently, and remove it when the animation ends.
Does this apply if I use React/Vue/Svelte? Yes. Frameworks batch DOM updates efficiently, but they run on the same main thread and pay the same style/layout/paint costs. The CSS-level advice (compositor-only animations, containment, careful promotion) applies unchanged.
Summary
The pipeline is JavaScript → Style → Layout → Paint → Composite. Style, layout, and paint run on the main thread and compete with your JavaScript inside a ~16ms frame budget; compositing runs on its own thread and is nearly free.
From that model, every rule follows:
- Reflow (layout) is the most expensive step. It's triggered by DOM changes, geometry-affecting styles, and forced synchronous reads like
offsetHeight. - Batch reads and writes to avoid layout thrashing; build subtrees in a
DocumentFragmentand attach once. - Repaint skips layout but isn't free. Large paint areas and heavy effects still cost time.
transformandopacityskip layout and paint. Express movement and fades in these two properties whenever you can.- Layer promotion is a scalpel, not a default. Each layer costs GPU memory, and mobile pays first.
- Verify in DevTools with Performance recordings, CPU throttling, and paint flashing instead of guessing.
Learn the five steps and the three-tier cheat sheet at the top, and performance advice stops being folklore: you can derive every rule yourself.