How Browsers Render
How HTML and CSS become pixels: the DOM and CSSOM, style, layout, paint and compositing — what each change costs, why layout thrashing and long tasks cause jank, and how to read a real trace. Measured in Chromium, not guessed.
The 30-second version
You scroll a product list and it stutters. A "simple" slide-in menu runs at a silky 60 frames per second on your laptop and drops to 20 on a cheap phone. Neither is a mystery once you know what the browser does to turn your HTML and CSS into pixels.
That stutter has a name: Jank. It happens when the browser can't produce a new Frame in time, so the screen shows the old picture again. To avoid it, you need to know which work your code triggers. This topic shows you, with measurements.
Five stations, always in order
Earlier means costlier
width re-runs layout, paint and composite. Changing color skips layout. Changing transform or opacity can skip layout and paint.One busy worker
Here is the most practical consequence. Both rules below slide a menu in from the left. The left one makes the browser lay out and repaint the page on every frame; the right one only moves an already-printed sheet:
.menu { position: fixed; left: -300px; transition: left 0.3s; }
.menu.open { left: 0; }.menu { position: fixed; left: 0; transform: translateX(-300px); transition: transform 0.3s; }
.menu.open { transform: translateX(0); }You'll see exactly why in §06 and §09, and you'll freeze a page on purpose to watch the difference. Along the way: how HTML becomes a tree (§03), why one <script> tag can keep a page blank for half a second (§03), why a loop that reads offsetWidth can be 100× slower than it needs to be (§07), and how to read the Performance panel in DevTools (§10).
Primer: frames, threads and the GPU
Three ideas carry this whole topic: frames, the main thread, and the GPU. If you've seen them before, skip to §03.
A screen is a flip book
A screen doesn't show motion. It shows still pictures, one after another, fast enough that your eye blends them. Each picture is a Frame. A typical screen refreshes 60 times per second (60 Hz). Many phones and newer laptops refresh 120 times (120 Hz). See the frame rate vs refresh rate definition for the difference.
Worked example. 1 second = 1,000 ms. At 60 Hz, a new frame is due every 1000 ÷ 60 ≈ 16.7 ms. At 120 Hz, every 1000 ÷ 120 ≈ 8.3 ms. That interval is the Frame budget. If the browser needs 25 ms to produce the next picture, it misses the 16.7 ms tick, and the screen shows the previous frame a second time. Your eye sees that as a stutter.
The main thread: one worker, many jobs
A Thread is one worker that runs code step by step. Each browser tab has one special thread, the Main thread, and it has a lot of jobs:
- running your JavaScript (event handlers, timers, React),
- parsing HTML,
- working out styles and layout,
- recording paint instructions.
It can only do one of those at a time. So if a click handler runs for 200 ms, nothing else on that list happens for 200 ms: no new frames, no reaction to the next click. (How the main thread picks the next job is the Event loop. Async / Await §07 explains it; §08 here adds where drawing fits in.)
The helpers: compositor thread and GPU
Two helpers take work off the main thread:
- The Compositor thread assembles the final frame from separately painted sheets, called layers. It can move or fade a layer without asking the main thread.
- The GPU is the graphics chip. It's very fast at filling and blending millions of pixels. Turning paint instructions into actual pixels (rasterization) and stacking layers usually happens with its help.
From bytes to the DOM (and why scripts block it)
The server sends your HTML as plain text, a few kilobytes at a time. The browser's HTML parser turns that text into the DOM: a tree of objects, one per element, that your JavaScript can read and change. (A "tree" just means every node has one parent and any number of children; Trees & Tries covers trees in depth.)
Two steps: tokens, then a tree
Parsing happens in two steps that run back and forth:
- The Tokenizer reads characters and recognises pieces: "start tag
pwithclass="intro"", "the textHello", "end tagp". - The tree builder takes each piece and adds it to the DOM. A start tag creates an element node and makes it the "current" parent; text becomes a text node inside it; an end tag closes it.
Two properties of the parser matter in practice:
- It's incremental. The DOM grows while the file is still downloading. The browser can paint the top of a long article before the bottom has arrived.
- It forgives mistakes. Unclosed tags and stray
</div>s never cause an error page. The HTML standard spells out exactly how to repair every kind of broken markup, so all browsers build the same tree from the same mess.
Then it hits a <script>
A plain <script src="app.js"> is Parser-blocking. When the parser reaches it, it stops, waits for the file to download, runs it, and only then continues with the next line of HTML.
Why so strict? Because the script might change what comes next. Old-fashioned code can call document.write("<p>…") to inject HTML right at that spot. Any script can also read the DOM built so far and expect it to stop exactly at its own tag. The parser can't know in advance that your script doesn't do these things, so it plays safe.
The fix is to tell the browser your script doesn't need to run at that exact spot. Here are the options, with the timings measured on a tiny page whose script takes 500 ms to download:
| Attribute | Downloads… | Runs… | Blocks the parser? |
|---|---|---|---|
none, in <head> | right away | as soon as it arrives | yes — page stays blank |
none, at end of <body> | early (the preload scanner below finds it) | when the parser reaches it and it has arrived | yes, but the content above is already there |
defer | in parallel | after the whole document is parsed, in document order | no |
async | in parallel | the moment it arrives, in any order | no (but it can pause parsing briefly to run) |
type="module" | in parallel, plus its imports | like defer | no |
<head>
<script type="module" src="/main.js"></script> <!-- what Vite generates -->
<script defer src="/legacy-widget.js"></script> <!-- non-module script that needs the DOM -->
<script async src="https://analytics.example/a.js"></script> <!-- independent, order doesn't matter -->
</head>CSS, the CSSOM and the render tree
While the HTML parser builds the DOM, every stylesheet goes through a separate CSS parser. The result is the CSSOM: all of the page's CSS rules as objects. Each rule is a selector (which elements it applies to, like .card or h1) plus declarations (what to set, like color: navy).
CSS blocks rendering, not parsing
A <link rel="stylesheet"> in <head> is Render-blocking. The HTML parser keeps going, but the browser won't paint anything until the stylesheet has arrived.
That sounds harsh, but the alternative is worse. If the browser painted before the CSS arrived, you'd see the page with default black-on-white styles for a moment, then watch it jump into its real design. That flicker even has a name: FOUC, a flash of unstyled content. Browsers chose "blank a bit longer" over "flash and jump".
Watch the difference. Again a tiny page, now with a stylesheet that takes 500 ms:
DOMContentLoaded fired), yet the screen stayed blank until the CSS arrived: stylesheets are render-blocking. First paint ≈ 524 ms.Look at the "slow CSS" numbers: DOMContentLoaded fired at about 10 ms (parsing was done), yet the first paint waited until 524 ms. The parser was never blocked; the painting was.
DOM + CSSOM → the render tree
To draw anything, the browser needs to know, for each element, its final CSS values. Style works these out (§05 steps through it), and pairs every element that will produce a box on screen with its values. That structure is the Render tree.
It is not a copy of the DOM:
<head>,<script>,<style>and friends never produce boxes, so they aren't in it.- An element with
display: noneis left out, along with everything inside it. - An element with
visibility: hiddenoropacity: 0stays in. It still has a box and still takes up space; it just isn't visible.
Try all three on the ad below. Then try clicking where the button used to be:
| Hidden with… | In render tree? | Takes space? | Painted? | Clickable / focusable? |
|---|---|---|---|---|
display: none | no | no | no | no |
visibility: hidden | yes | yes | no | no |
opacity: 0 | yes | yes | yes, fully transparent | yes |
The whole pipeline on one tiny page
Time to put the pieces together. Below is a complete page: a heading, a gold card, and a span that CSS hides. Step through it with Next (or →) and watch each tree fill in. The layout numbers aren't made up: they're the boxes Chromium computed for this exact page in a 400-pixel-wide window.
1<!doctype html>2<html>3<head>4 <style>5 h1 { color: navy; margin: 0; }6 .card { width: 200px;7 padding: 10px;8 background: gold; }9 .hidden { display: none; }10 </style>11</head>12<body>13 <h1>Hi</h1>14 <div class="card">15 Box <span class="hidden">secret</span>16 </div>17</body>18</html>
<!doctype html> (use modern standards mode) and a <html> start tag. The tree builder turns each start tag into a node in the DOM, the tree of objects your JavaScript can touch.Here is the same journey as a table: what goes into each stage, what comes out, and the print-shop job it matches.
| Stage | Input | Output | Print shop |
|---|---|---|---|
| Parse | HTML and CSS text | the DOM and the CSSOM | typesetting |
| Style | DOM + CSSOM | a Computed style for every element | applying the style guide |
| Layout | elements + computed styles | a box for each, with x, y, width and height in pixels | page layout |
| Paint | the boxes | a Display list (drawing instructions) | printing |
| Composite | painted layers | the final Frame on screen | assembling the sheets |
Two of these names deserve a closer look:
- Style recalculation is what DevTools calls the style stage ("Recalculate Style"). Its job is matching: for each element, which of the thousands of CSS rules apply, and which one wins when several set the same property.
- Layout is also called reflow, especially in Firefox. Same thing.
After the first frame: only redo what's dirty
The last step in the stepper is the important one for everyday work. When JavaScript changes something, the browser doesn't rebuild everything. It marks the changed element as dirty ("needs updating"), and the next time it produces a frame, it re-runs only the stages that change needs, and only for the parts of the page affected.
That raises the question the next four sections answer: which stages does each kind of change need, and what does that cost?
What each change costs: layout vs paint vs composite
The browser always starts with style (it has to notice your change). Where it goes from there depends on the property. To see this for real, we changed one property at a time on a small box and recorded a Chrome trace of the next frame. A trace is Chrome's internal log of every step it ran and how long each took; §10 shows how to record one yourself.
The three groups
| Group | Example properties | Stages that run |
|---|---|---|
| Layout (geometry) | width, height, padding, margin, border-width, top/left, font-size, font-family, display, the text content itself | style → layout → paint → composite |
| Paint only | color, background-color, background-image, box-shadow, border-color, outline, visibility | style → paint → composite |
| Composite only | transform, opacity (and usually filter) once the element is on its own layer | style → composite |
The table is a guide, not a law: engines keep optimising, and they differ in the details. But the first two rows are stable everywhere, and transform and opacity are the two properties every major browser can animate without layout or paint.
Size matters more than the property
The box above is tiny, so even its layout costs only 0.16 ms. What the stage costs depends on how much of the page it touches. The same traces, on a container holding 3,000 rows of text:
| Change on the 3,000-row container | Style | Layout | Pre-paint | Paint |
|---|---|---|---|---|
width (every row re-wraps its text) | 0.17 ms | 10.6 ms | 4.7 ms | 0.6 ms |
font-size (every row's text is re-measured and re-wrapped) | 3.1 ms | 20.1 ms | 4.3 ms | 0.6 ms |
color (inherited by every row) | 2.7 ms | — | 0.01 ms | 1.4 ms |
Measured on this machine (headless Chromium 153, Chrome tracing), median of 7 frames; the rows inherit the container's font size and colour. Individual frames varied (font-size layout: 17–37 ms).
The width change alone ate 10.6 ms of a 16.7 ms frame budget, before your JavaScript ran at all. The font-size change was twice as expensive: every character of every row had to be measured again at the new size before the lines could be re-wrapped. Changing text size is one of the most expensive layout changes there is. And notice the colour change: no layout, but style recalculation still visited 3,000 elements, because every row inherits the colour.
Reflow vs repaint, and layout thrashing
Two words you'll meet in every performance article:
- Reflow = layout running again (positions and sizes recalculated). Expensive: it can involve much of the page.
- Repaint = paint running again without layout (pixels change, nothing moves). Usually cheaper.
Every reflow is followed by a repaint, but not the other way round. That's the table from §06 in two words.
The browser is lazy, on purpose
When you write el.style.width = "300px", the browser does not run layout right then. It marks the element dirty and moves on. Layout runs later, once, just before the next frame. So 100 style writes in a row cost one layout, not 100. That's a good deal.
Unless you ask a question that needs the answer now:
box.style.width = "300px"; // write: layout is now dirty
const w = box.offsetWidth; // read: "how wide is it?" ← the browser must run layout immediatelyTo answer offsetWidth correctly, the browser has to run layout on the spot, in the middle of your JavaScript. That's a Forced synchronous layout. Once is fine. The trouble starts in a loop.
Layout thrashing
for (const bar of bars) {
const w = bar.offsetWidth; // READ → forces layout (the last write made it dirty)
bar.style.width = w + 40 + "px"; // WRITE → dirty again
}Each iteration's write makes layout dirty; the next iteration's read forces it to run again. With 300 bars you get 300 layouts in one go. This is Layout thrashing.
Don't take our word for it. This runs in your browser, on real elements:
Here's what the widget measured in headless Chromium on the author's machine (median of 7 runs). The timed code starts from a clean layout and ends by forcing one final layout, so both versions pay for it:
| Bars | Interleaved | Batched | Layouts in the timed code (interleaved / batched) |
|---|---|---|---|
| 100 | 4.7 ms | 0.4 ms | 100 / 1 |
| 300 | 26 ms | 0.7 ms | 300 / 1 |
| 1,000 | 391 ms | 2.2 ms | 1,000 / 1 |
The layout counts were checked in Chrome traces. In the batched version the reads find layout already clean, so they cost nothing; the one layout happens at the end. Notice the interleaved time grows much faster than the bar count: 10× the bars cost about 100× the time. Each forced layout re-checks the whole list of bars, so 10× the bars means 10× as many layouts, each about 10× bigger. At 1,000 bars, one click froze the page for about 0.4 seconds, and a slow phone is several times slower than this machine.
One detail of the widget matters here: its bars sit in a box with contain: strict and a fixed height. That tells the browser nothing inside the box can affect the rest of the page (§11 explains containment), so each forced layout only redoes the bars. Without it, on this long article, the same 1,000-bar run took 0.9–2.3 seconds, because every forced layout also had to re-check the page around the widget.
The fix: reads first, then writes
for (const bar of bars) {
const w = bar.offsetWidth;
bar.style.width = w + 40 + "px";
}const widths = bars.map((bar) => bar.offsetWidth); // all reads
bars.forEach((bar, i) => { // all writes
bar.style.width = widths[i] + 40 + "px";
});Which reads force layout?
Anything that has to know a current size or position. The common ones:
offsetWidth,offsetHeight,offsetTop,offsetLeft,clientWidth/clientHeight,scrollWidth/scrollHeightgetBoundingClientRect(),getClientRects()scrollTop/scrollLeft(reading them, and writing them),scrollIntoView()getComputedStyle(el)and then reading a layout value like.widthinnerText(it needs to know what's visible),focus()(it may scroll)window.innerWidth/innerHeight,window.scrollY
None of these is slow on its own. They're slow right after a write.
When reads and writes live in different places
In a real app, the read and the write are often in different components or libraries, so you can't just reorder two lines. Two tools help:
// read now, during the event…
const top = card.getBoundingClientRect().top;
// …write just before the next frame, when the browser is going to run layout anyway
requestAnimationFrame(() => {
tooltip.style.transform = `translateY(${top}px)`;
});One caveat: top was read a moment earlier. If something moves in between (the user scrolls, an image loads), the value is slightly stale. For a tooltip that's fine; when it isn't, do the read inside the rAF callback too, before any writes.
The second tool is to not read at all. If you need to react to an element's size, ResizeObserver tells you when the size changes, with the new size already computed at a point where reading is cheap, instead of you polling offsetWidth.
The frame budget, and what blocks it
Where drawing fits in the event loop
The Event loop runs one task at a time: a click handler, a timer callback, a network response. After each task it runs all pending microtasks (promise callbacks, code after await). Async / Await §07 explains that part step by step.
The browser adds one more thing to the loop. Between tasks, if a new frame is due (about every 16.7 ms at 60 Hz), it runs the rendering steps:
So, in order: one task → all microtasks → (if a frame is due) requestAnimationFrame callbacks → style → layout → paint → commit (hand the result over to the compositor thread, which draws it). Two consequences:
- Nothing you write to the DOM shows up while your task is still running. The main thread only renders your changes once you give it back.
- Whether a
setTimeout(fn, 0)callback runs before or after the next frame isn't guaranteed. If you want "right before the next paint", userequestAnimationFrame; that's exactly what it's for.
function animate(now) { // now = timestamp of this frame, in ms
ball.style.transform = `translateX(${(now / 5) % 300}px)`;
requestAnimationFrame(animate); // ask again for the next frame
}
requestAnimationFrame(animate);The budget, in practice
All the main-thread work for one frame — your handler, microtasks, rAF callbacks, style, layout, paint — has to fit inside the Frame budget. Play with the model below, then measure your own screen:
Two lessons from the model:
- Missing the budget by a little costs a lot. At 60 Hz, 12 ms of work per frame gives 60 fps; 24 ms gives 30. A frame can only appear on a screen tick, so 1 ms over is as bad as 16 ms over.
- Fast screens halve the budget. On a 120 Hz phone you have 8.3 ms. Work that was smooth at 60 Hz now hits only half the ticks.
And the budget isn't all yours: the browser needs part of it for style, layout and paint, so aim for well under 10 ms of JavaScript per frame during animations and scrolling.
Long tasks
A Long task is any task over 50 ms. During it, the page can't draw and can't respond: a user who clicks or types sees nothing happen. Google's INP metric (Interaction to Next Paint, one of the Core Web Vitals) measures exactly this: the delay between a user's click, tap or key press and the next frame that shows a response. Long tasks are the main thing that makes it bad. (Web Performance goes further into measuring and improving it.)
The fix is to break the work into pieces and give the thread back between them, so the browser can draw and handle input in the gaps:
async function processAll(items) {
for (let i = 0; i < items.length; i++) {
handle(items[i]);
if (i % 200 === 199) await yieldToBrowser(); // every 200 items, let it breathe
}
}
function yieldToBrowser() {
// scheduler.yield() resumes you ahead of other queued tasks; fall back to a timer elsewhere.
if (globalThis.scheduler?.yield) return scheduler.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
}Work that's purely computation — parsing a big file, image processing, search indexing — doesn't need the DOM at all. Move it to a Web Worker, a separate thread that can't touch the page but never blocks it.
Layers and the GPU: why transform and opacity are cheap
Back to the print shop. The printer doesn't print the whole page as one sheet. Some parts go on their own transparent sheet: a sidebar that slides, a video, a modal that fades in. The assembler stacks the sheets to make the final page. To slide the sidebar, the assembler just moves its sheet. Nothing is re-typeset or reprinted.
Those sheets are compositor layers. The assembler is the Compositor thread, and it works with the GPU, which is extremely good at moving and blending pictures.
What the compositor can do alone
Once a layer is painted, the compositor can change it without any help from the main thread. From CSS, the two things you control that every browser handles this way are:
- where it is, how big, how rotated →
transform - how see-through it is →
opacity
(Chromium can also do some filter effects this way, and the compositor scrolls the page by itself too.) That's the real reason transform and opacity are "composite-only" (§06). It also means a CSS animation or transition of transform or opacity can be handed to the compositor thread entirely. It keeps running even when the main thread is completely stuck.
Don't believe it? Start the three balls, then freeze this tab's main thread for 1 second:
We measured the same thing in a Chrome trace: a ball animated with CSS, while JavaScript blocked the main thread for 1 second.
| Animated property | Frames the screen asked for | Frames actually drawn |
|---|---|---|
left | 60 | 0 |
transform | 60 | 60 |
Measured on this machine (headless Chromium 153, Chrome tracing).
Notice the yellow ball in the demo: it animates transform too, but from JavaScript, in a requestAnimationFrame loop. It froze, because the code that moves it needs the main thread every frame. Cheap properties only escape the main thread when the animation itself is declarative: a CSS animation, a CSS transition, or the Web Animations API (el.animate(…)).
sidebar.animate(
[{ transform: "translateX(-300px)" }, { transform: "translateX(0)" }],
{ duration: 300, easing: "ease-out" },
);Asking for a layer: will-change
Browsers create layers automatically for some things (videos, canvases, running transform or opacity animations). You can also ask ahead of time:
.drawer { will-change: transform; } /* "this is about to move; give it its own layer now" */will-change avoids the hiccup we saw in §06, where the first transform cost a layout and a paint while the layer was set up. With the hint, that setup happens early, before the animation starts.
Layers aren't free
Every layer is a separate picture stored in GPU memory, and the compositor must manage and stack all of them every frame. Memory first, as a worked example: a picture uses 4 bytes per pixel (red, green, blue, transparency). A full-screen layer on a 1920 × 1080 display is 1920 × 1080 × 4 ≈ 8 MB. On a phone with a high-density screen, a layer can easily be bigger. A dozen of those is real memory on a device that has little.
Then management cost. We gave 2,000 small squares will-change: transform and timed a frame where one square changed colour:
| Page | Layers | Commit time per frame |
|---|---|---|
| 2,000 squares, no hint | 4 | about 0.1 ms |
2,000 squares with will-change: transform | 2,004 | about 3–4 ms |
Measured on this machine (headless Chromium 153), median of 20 frames, 3 runs. The ratio swung between roughly 7× and 40× from run to run; the direction never changed.
That's several milliseconds of every frame, a big slice of an 8.3 ms budget on a 120 Hz screen, spent for no visual benefit. Doing this by accident is called layer explosion. A common way to cause it: * { will-change: transform; } or a "performance" CSS snippet copied from the internet.
Reading the Performance panel
Guessing about performance is how you end up "optimising" the wrong thing. The Performance panel in Chrome's DevTools records a trace: every task, function call, style, layout and paint step on every thread, with timestamps. Learning to read one is the single most useful skill in this topic.
Recording a trace
- Open DevTools (F12, or Cmd+Opt+I on a Mac) and go to the Performance tab.
- In its settings (⚙), set CPU: 4× slowdown. Your laptop is much faster than your users' phones; this narrows the gap.
- Press Record (⏺), do the slow thing (click, scroll, type), and press Stop after a few seconds.
- Find the Main track. That's the main thread, drawn as a Flame chart.
Reading the flame chart
Time runs left to right. Each bar is one piece of work; the bars directly under a bar are the work it called. A wide bar took a long time. Colours tell you the kind of work:
Yellow: scripting
Purple: rendering
Green: painting
Here is a real trace, recorded in headless Chromium on a test page with 400 bars and this click handler:
button.addEventListener("click", function growBars() {
for (const b of document.querySelectorAll(".bar")) {
b.style.width = (b.offsetWidth + 10) + "px"; // read, then write: thrashes
}
});How to read it, top to bottom:
- The task bar (grey) is 62.5 ms, past the 50 ms long-task line. DevTools marks such a task with red stripes and a red triangle. That's your first clue.
- Under it, yellow: the click event, then
growBars. JavaScript is "responsible" for the whole 60 ms… - …but the row under that is almost solid purple. 400 forced layouts, about 54 ms of the 60. The JavaScript itself is only the thin slivers between them (zoom in to see). In real DevTools, each of those purple bars carries a red corner and the warning Forced reflow is a likely performance bottleneck, with a link to the line of code that caused it.
- The frames due at 16.7, 33.3 and 50 ms never happened. The next drawing work only ran at 62.5 ms, after the task ended: paint, then layerize and commit to the compositor.
The diagnosis takes one look: the fix is §07's read-then-write batching, not "faster JavaScript".
Measuring in production
DevTools shows your machine. To see what real users experience, the browser can report slow frames to your own code:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// entry.duration: how long this frame's work took, in ms
sendToAnalytics({ type: entry.entryType, ms: Math.round(entry.duration) });
}
}).observe({ type: "long-animation-frame", buffered: true });Observability covers collecting metrics like these from real users (RUM).
At scale: containment, content-visibility, virtualization, fonts, React
Everything so far comes down to one lever: how much of the page each stage has to touch. On a big page, the wins come from making that number small. Four techniques, from least to most invasive, then how React fits on top.
1. Skip what's off screen: content-visibility
With content-visibility: auto, the browser skips style, layout and paint for an element's contents while it's far off screen, and does the work only when it scrolls near.
.feed-item {
content-visibility: auto;
contain-intrinsic-size: auto 120px; /* guess the height until it's really rendered */
}We inserted article-like sections (a heading, a paragraph, a short list each) with innerHTML, then timed only the forced style + layout that followed (HTML parsing isn't included):
| Sections | Plain | With content-visibility: auto |
|---|---|---|
| 2,000 | 84.4 ms | 5.3 ms |
| 10,000 | 432.9 ms | 31.2 ms |
Measured on this machine (headless Chromium 153), median of 5.
content-visibility is supported in all current major browsers (Safari since version 18), but treat it as an enhancement: browsers without it simply do the full work.
2. Fence off what changes: CSS containment
CSS containment is a promise to the browser: "nothing inside this box affects layout or paint outside it."
.widget { contain: layout paint; } /* inside changes can't move or draw outside this box */
.chat-panel { contain: strict; } /* + its size doesn't depend on its contents (set a size!) */With that promise, a change inside the box can't move anything outside it, so the browser can limit layout and paint to the box. It matters most for independently updating pieces of a big page: a live ticker, a chat panel, a third-party embed. content-visibility: auto actually applies containment for you; that's how it can skip the work safely.
3. Don't create it at all: virtualization
The cheapest DOM element is one that doesn't exist. A 10,000-row table renders maybe 20 visible rows. Virtualization renders only those (plus a few spare), positions them inside a tall empty container so the scrollbar looks right, and swaps rows as you scroll. This one is a real React component; switch modes and compare:
In practice you'd use a library (TanStack Virtual, react-window, or your table or grid component's built-in mode) because the hard parts are variable row heights, keyboard navigation and keeping the browser's find-in-page useful. The cost is real: rows that don't exist can't be found with Ctrl+F or reached by screen readers, so prefer content-visibility when the list is long but not huge.
4. Fonts and layout shift
A web font downloads after the CSS that asks for it. Until it arrives, the browser either hides the text or shows a fallback font. When the real font arrives, every line of text changes width, which means style + layout for all of that text, and often visible lines jumping. That jump is a Layout shift, and Google's CLS score counts it against you. The same happens when an image without width and height attributes loads and pushes content down.
<img src="/hero.webp" width="1200" height="600" alt="…"> <!-- browser reserves the space up front -->@font-face {
font-family: "Brand";
src: url("/brand.woff2") format("woff2");
font-display: swap; /* show fallback text immediately, swap when the font arrives */
}Those two lines cover the basics: reserve space for images, and decide what text shows while the font loads. Web Performance goes deeper into font loading strategies and measuring CLS.
How React fits on top
React doesn't paint anything. When state changes, React renders (calls your components to get a description of the UI) and then commits (applies the minimum set of DOM writes). After that, React's task ends, and the browser runs the pipeline from this topic: style, layout, paint, composite. React's rendering model explains render and commit in detail.
Three consequences for performance:
- Batching is built in. Several state updates in one event are committed together, so the browser sees one batch of DOM writes and runs layout once. You get §07's "writes together" for free.
useLayoutEffectruns after the DOM writes but before the browser paints. Reading a size there (ref.current.getBoundingClientRect()) forces a synchronous layout. That's fine and intended once, for something like positioning a tooltip. Fifty list items each measuring themselves in a layout effect is layout thrashing with extra steps. React Hooks covers effect timing.- React can't make a 10,000-node DOM cheap. A fast React render still produces DOM the browser must style and lay out. When the browser stages dominate the trace, the fix is less DOM (virtualization,
content-visibility), not memoising components.
Pitfalls gallery
Animating top, left, width or height
Each frame re-runs layout and paint on the main thread, so the animation stutters as soon as anything else is busy (§09 showed it freezing completely).
.toast { transition: bottom 0.3s; bottom: -80px; }
.toast.show { bottom: 16px; }.toast { transition: transform 0.3s; bottom: 16px; transform: translateY(96px); }
.toast.show { transform: translateY(0); }Measuring inside a loop that also writes
Reading offsetHeight, getBoundingClientRect() or scrollTop right after a style write forces layout; in a loop, that's one layout per item (§07). It often hides across functions: updateRow() writes, isVisible() reads, and both run for every row.
Fix: collect all reads first, then do all writes; or schedule the writes with requestAnimationFrame.
Driving visuals from a scroll listener
window.addEventListener("scroll", () => {
header.classList.toggle("small", sentinel.getBoundingClientRect().top < 0); // main-thread work every frame
});Modern browsers fire scroll at most once per frame, so this isn't thrashing. The problems are subtler:
- It runs on the main thread every frame you scroll. The scrolling itself is done by the compositor (§09), but this handler, plus the style change it causes, has to fit in the frame budget too. Add a few of these and scrolling gets heavy.
- It lags behind. The compositor moves the page immediately; the handler's change shows up a frame or more later, or much later if a long task is running. Headers and parallax effects visibly jitter.
- A wheel or touch listener can block scrolling itself. If it might call
preventDefault(), the browser must wait for your JavaScript before it scrolls. Mark such listeners passive ({ passive: true }) so it doesn't have to wait.
Fix: let the browser do it. An IntersectionObserver tells you when an element enters or leaves view, without any per-frame code. For pure visuals, CSS position: sticky or a Scroll-driven animation needs no JavaScript at all.
will-change on everything
* { will-change: transform; } or a hint on every card in a list creates a layer per element: GPU memory for each, and in our measurement 3–4 ms of extra commit work every frame instead of about 0.1 ms (§09). Fix: hint only what's about to animate, and remove the hint after.
A plain script tag in the head
<script src="app.js"> without defer, async or type="module" stops the parser; the page stays blank until the script downloads and runs (520 ms instead of 28 ms in our test, §03). Fix: type="module" or defer.
Hiding things with opacity: 0
The element is invisible but still takes clicks and keyboard focus, and screen readers may still announce it (§04). An invisible "Delete" button over a real one is a nasty bug. Fix: display: none (or the hidden attribute) to remove it; visibility: hidden if it must keep its space. Fade with opacity, then hide at the end.
One giant task in an input handler
Filtering 50,000 items on every keystroke in one go makes typing lag: each keypress waits for the previous task to finish before anything is drawn (§08). Fix: debounce the input (wait until the user pauses typing, then filter once), split the work and yield between chunks, or move it to a Web Worker.
content-visibility without a size
content-visibility: auto alone lets skipped sections collapse to zero height, so the scrollbar shrinks and jumps as you scroll and sections render. Fix: always pair it with contain-intrinsic-size: auto <your best guess> (§11).
Images and ads without reserved space
Content that arrives late and has no reserved box pushes everything below it down: a layout for the page and a visible jump for the reader. Fix: width and height attributes on images, aspect-ratio or a fixed-size slot for embeds and ads.
Check yourself: predict what the browser does
Commit to an answer before you click. Each explanation points back to the section that covers it.
.card is already on its own compositor layer. You run this once:
card.style.transform = "translateX(50px)";const items = document.querySelectorAll(".item"); // 500 items
for (const el of items) {
const h = el.offsetHeight; // read
el.style.height = h + 10 + "px"; // write
}The <head> contains <script src="big.js"></script> (no defer/async). big.js takes 3 seconds to download. What does the user see for those 3 seconds?
A modal's close button is hidden with one of these. Which one leaves it invisible but still clickable?
A 120 Hz screen. Your scroll handler plus style, layout and paint take 12 ms every frame. What frame rate do users get?
During a 1-second long task, an animation of transform kept moving but an animation of left froze. Why?
Cheat sheet
type="module" or defer. Keep first-screen CSS small; mark the rest with media.display: none leaves it. visibility: hidden keeps the space. opacity: 0 is still clickable.el.animate() of those two survive a busy main thread.will-change only on what's about to move. 2,000 layers added 3–4 ms to every frame's commit.content-visibility: auto, containment, virtualization for long lists.| If you want to… | Do this | Avoid |
|---|---|---|
| Move or slide something | transform: translate() | top / left / margin |
| Fade something | opacity | toggling visibility every frame |
| Resize visually in an animation | transform: scale() | animating width / height |
| Load your app's script | <script type="module"> or defer | plain <script src> in <head> |
| Measure then update many elements | reads first, then writes; writes in requestAnimationFrame | offsetWidth inside a write loop |
| React to size or visibility changes | ResizeObserver, IntersectionObserver | polling sizes in scroll handlers |
| Hide something completely | display: none / hidden | opacity: 0 |
| Show a 10,000-item list | virtualization or content-visibility: auto | rendering every row |
| Find what's slow | DevTools Performance panel, LoAF in production | guessing |