If you have ever wondered what actually happens between React calling setState and something appearing on your screen, the answer lives in the browser's rendering pipeline and painting is its final, visible act. Understanding it will change how you think about performance, animations, and hooks like useLayoutEffect.
The Pipeline Before the Paint
Painting does not happen in isolation. It sits at the end of a sequence the browser runs every time something visual needs to change.
Parse -> Style -> Layout -> Paint -> Composite
When your React component renders and the DOM updates, the browser kicks off this chain. First it recalculates styles, then it runs layout, also called reflow, to figure out the size and position of every element on the page. Only after those two steps does the browser move on to painting.
What painting actually means
In the painting phase, the browser converts each box calculated during layout into actual pixels on the screen. This includes drawing text, colors, borders, shadows, and replaced elements like buttons and images.
The browser does not produce one giant image in one pass, though. Drawing to the screen is generally broken into several layers so repainting can be done even faster than the initial paint. Elements that use opacity, transform, or will-change often get promoted to their own GPU layer.
After layers are painted, the final step, compositing, assembles them in the correct stacking order to produce the frame you see.
The three paths through the pipeline
1 Layout change
The browser must reflow, repaint, and composite.
- Examples: width, height, top
- Most expensive path
2 Paint-only change
Layout is skipped, but the browser still repaints and composites.
- Examples: background-color, box-shadow
- Middle cost path
3 Composite-only change
Layout and paint are skipped and the browser jumps straight to compositing.
- Examples: transform, opacity
- Fastest animation path
This is why performance-conscious React developers prefer animating transform over left or top, and opacity over display.
Where React fits in
React batches DOM mutations and commits them all at once, but the browser still has to run its own pipeline after every commit.
import { useRef, useState, useLayoutEffect, useEffect } from 'react';
function AnimatedBox() {
const boxRef = useRef(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const measured = boxRef.current.getBoundingClientRect().width;
setWidth(measured);
}, []);
useEffect(() => {
console.log('Browser has painted. Width was:', width);
}, [width]);
return (
<div ref={boxRef} style={{ width: '50%', background: 'steelblue' }}>
Rendered width: {width}px
</div>
);
}
The key distinction is that useLayoutEffect fires synchronously before paint, while useEffect fires after paint. Doing expensive layout measurements in useEffect can cause a visible flash. useLayoutEffect prevents this by acting before any pixels are committed to screen.
// Expensive: triggers layout and paint on every frame
<div style={{ left: `${x}px` }} />
// Cheap: compositor-only, skips layout and paint
<div style={{ transform: `translateX(${x}px)` }} />
The 16ms budget
To ensure smooth scrolling and animation, everything on the main thread, including calculating styles, reflow, and paint, must take less than 16.67ms to fit a 60fps frame budget. Miss it and you get dropped frames, or jank.
How browsers differ
All three major browser engines share the same conceptual pipeline, but their implementations diverge under the hood. Chrome, Edge, and Opera run on Chromium and Blink. Firefox uses Gecko. Safari is built on WebKit.
What this means for your React code
Use transform and opacity for animations to stay on the compositor thread. Measure DOM geometry in useLayoutEffect, not useEffect, to avoid flicker. Avoid reading layout properties like offsetHeight inside loops because it forces the browser to flush layout repeatedly.
Once you have a mental model of the pipeline, performance problems stop feeling like mysterious glitches and start looking like predictable outcomes of the steps you ask the browser to take.