The DOM and the render pipeline: from HTML to pixels
Tuesday, 14:20: on a mid-range Android phone, StayHub's search results scroll at 11 frames per second. On Karim's laptop the same page is perfectly smooth, and Maya says 60% of searches come from phones.
How a browser turns text files into the page you see, and why some changes are free while others make the page stutter.
- Problem
A hotel search page must show results quickly and stay smooth while the guest scrolls, filters and opens photo galleries on a mid-range phone.
- Why the simple system fails
Treating the page as a static document: render-blocking scripts delay first paint, and JavaScript that changes layout in a loop forces the browser to recompute the whole page dozens of times per frame.
- Engineering concept
The browser builds models (DOM, CSSOM), combines them into a render tree, then runs layout, paint and composite. Each change re-runs part of that pipeline; cheaper changes skip stages.
- Technology
HTML, CSS, JavaScript, the DOM API, defer/async scripts, CSS transforms and opacity, requestAnimationFrame, browser DevTools performance panels.
- Trade-offs
Rich, dynamic UIs need DOM changes, and every change costs some pipeline work. You trade developer convenience (re-render everything) against frame budget (touch only what changed, in the cheapest stage).
The problem
2 min- Maya · Product managerSearch results stutter when I scroll: 11 frames per second. 60% of searches are on phones like this.
- Karim · CTOPerfectly smooth on my laptop. Same page.
- Lina · Senior engineer, your mentorGuess first. Then we look at what the browser does in every frame.
The big idea
4 minEvery change re-runs part of the render pipeline. Make changes start as late in it as possible.
It isn't the phone. It's how often our code makes the browser redo layout. The laptop does the same wasted work, just fast enough to hide it.
How it works
5 min- 1HTML → DOM
The browser parses HTML into the , a tree of element objects. JavaScript changes the page by changing this tree.
- 2CSS → CSSOM
Style rules are parsed into the .
- 3Render tree
Visible elements plus computed styles form the .
display: noneis left out. - 4Layout
Layout works out every box's size and position. Redoing it is a : change a size and neighbours may move.
- 5Paint
Text, colours and images are drawn into layers: a when redone.
- 6Composite
Layers are stacked into the frame, often on the GPU ().
All of this shares the main thread with our JavaScript, within a frame budget of 16.7 ms at 60 fps. A change re-runs the pipeline from the earliest stage it affects: width or top → layout onwards (expensive); color → paint onwards; transform or opacity → often composite only (cheap).
Our scroll handler reads each card's position, then writes a new one, for ~25 cards. Each read after a write forces a layout right now: . About 90 ms per frame: 11 fps.
Watch a decision being made
4 minLina fixes the stuttering search page
Search results scroll at 11 fps on a mid-range phone and smoothly on a laptop. Find out why and fix it, without guessing.
- 1Reproduce on a slow CPU, find the biggest slice
DevTools performance panel, CPU slowed 4×, record a scroll. One frame: ~90 ms, layout 80 ms, many small "Layout" blocks inside our handler.
Why: On a fast laptop the problem hides. Many layouts in one handler is the signature of forced layouts.
- 2Batch reads, then writes
Read all positions first, then do all writes in
requestAnimationFrame, so layout runs once per frame.tsconst ys = visibleCards.map((c) => c.offsetTop); // all reads requestAnimationFrame(() => { visibleCards.forEach((c, i) => { c.style.transform = `translateY(${ys[i] + shift}px)`; // all writes }); });Why: Reads are cheap when nothing changed since the last layout. Grouping removes 24 of the 25 layouts.
- 3Move with transform
Write
transforminstead oftop; make the sticky price barposition: stickyinstead of moving it in JavaScript.Why: A transform changes neither geometry nor pixels, so the browser can skip layout and paint.
- 4Measure again on the phone
With the CPU slowed 4×: about 6 ms per frame, 60 fps. Then confirm on a real mid-range phone.
Why: A fix you haven't measured is a hope.
In real life
3 minFigma: draw on a canvas, skip the DOM
- Has publicly described rendering its design canvas itself, with WebGL
- Thousands of shapes move at once; a DOM node each would mean constant layout
- Text, selection and accessibility must be rebuilt by hand
Worth it for a drawing surface with huge numbers of moving objects.
Wikipedia: plain HTML, small DOM
- Articles are mostly server-built HTML and CSS, little JavaScript
- Fast on old phones; text, links and screen readers come free
Worth it when the page is mostly content to read.
What goes wrong
3 minEach frame triggers layout and paint; slow devices drop frames.
Animate transform and opacity.
React decides which DOM changes to make; the browser still runs layout and paint for each.
Keep DOM changes per interaction, and the DOM itself, small.
When the image loads, the browser reflows and content jumps.
Set width/height or aspect-ratio.
Trade-offs
3 min| Option | Fits when | You pay | Main risk |
|---|---|---|---|
| Re-render big parts on each change | Small pages, internal tools | Layout work per interaction | Janky on slow phones |
| Targeted, minimal updates | Large lists, frequent updates | More code and bookkeeping | Stale UI bugs |
| Transform/opacity animations | Anything that moves or fades | GPU memory per layer | Not every effect fits |
- Karim · CTOOn every filter change, throw away the list and rebuild all 200 cards. Ship today.
- Lina · Senior engineer, your mentorOn Maya's phone that's about 200 ms per tap. Taps will feel ignored.
- Karim · CTOUpdating only changed cards means more code and more ways to show stale cards.
Your job as tech lead
4 minClaude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.
- Which device you judge smoothness on: a mid-range Android phone, with 60 fps (16.7 ms per frame) as the bar.
- Which third-party scripts may sit on the page, and whether they may block first paint.
- Which animations are worth having, and that they move things with
transformoropacity.
On a mid-range Android phone, StayHub's search results (200 cards) scroll at about 11 fps: ~90 ms per frame, ~80 ms layout. The scroll handler reads offsetTop and writes style.top for ~25 visible cards. Stay under the 16.7 ms budget: no position reads after style writes in one loop, move the sticky bar with transform, use IntersectionObserver for visibility. Don't change the search API or card design. Plan first, then traces with 4× CPU slowdown before and after, with time per frame.
- In the traces, "Layout" shrinks from ~80 ms to a few ms per frame.
- No loop both reads a position (offsetTop, getBoundingClientRect) and writes a style.
- Any new
<script>in<head>hasdeferorasync.
- It animates
top,leftorwidthfor something that moves every frame. - It throttles the scroll handler so the page updates late instead of smoothly.
- It judges speed only on a fast laptop, or measures nothing.
Check yourself
2 minwindow.addEventListener("scroll", () => {
for (const card of cards) {
if (card.getBoundingClientRect().top < window.innerHeight) {
card.style.opacity = "1";
card.style.marginTop = "0px";
}
}
});Next episode: Google shows StayHub's hotel pages with no description, and the phone waits for 300 KB of JavaScript before showing a single hotel. Who should build the HTML?
Mark it complete to update your skill map.
Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Visual diagram, Analogy, Check your understanding, Practice ladder, Explain it yourself.
Technical deep dive
Two pieces of code decide most of what happens: how the page loads its scripts, and how JavaScript changes the DOM afterwards.
<head>
<!-- Render-blocking by design: the browser waits for CSS before first paint. Keep it lean. -->
<link rel="stylesheet" href="/app.css" />
<!-- defer: download in parallel, run after the HTML is parsed, in order. -->
<script src="/app.js" defer></script>
</head>
<body>
<ul id="results">
<li class="card"><img src="/h/42.jpg" width="320" height="200" alt="Hotel Alfama" /> Hotel Alfama · €140</li>
</ul>
</body>Lina fixes the stuttering search page
The search results page scrolls at 11 fps on a mid-range Android phone and smoothly on a laptop. Find out why and fix it, without guessing.
- 1Reproduce on a slow CPU
Open DevTools' performance panel, slow the CPU down 4×, scroll the results for 3 seconds and record.
Why: On a fast laptop the problem hides. You can only fix what you can see.
- 2Find the biggest slice
One scroll frame takes about 90 ms: layout 80 ms, scripting 4, paint 5, composite 1. Many small purple "Layout" blocks sit inside our scroll handler.
Why: Many layouts inside one handler is the signature of forced layouts, not of a big page.
- 3Name the cause
The handler reads
offsetTopthen writesstyle.top, card after card. Each read after a write forces a synchronous layout: about 25 per event.tsfor (const card of visibleCards) { const y = card.offsetTop; // READ → forces layout card.style.top = y + shift + "px"; // WRITE → invalidates layout }Why: The cause picks the fix. A smaller page wouldn't help much; stopping the read-write ping-pong does.
- 4Batch reads, then writes
Read all positions first, then do all writes inside
requestAnimationFrame, so layout runs once per frame.tsconst ys = visibleCards.map((c) => c.offsetTop); // all reads requestAnimationFrame(() => { visibleCards.forEach((c, i) => { c.style.transform = `translateY(${ys[i] + shift}px)`; // all writes }); });Why: Reads are cheap when nothing has changed since the last layout. Grouping them removes 24 of the 25 layouts.
- 5Move with transform
Write
transforminstead oftop, and make the sticky price barposition: stickyinstead of moving it in JavaScript.Why: A transform changes neither geometry nor pixels, so the browser can skip layout and paint and only composite.
- 6Measure again on the phone
New recording with the CPU slowed 4×: about 6 ms per frame, 60 fps. Then check on a real mid-range phone.
Why: A fix you haven't measured is a hope. It also shows the next bottleneck, if any.
| Change | Example properties | Stages re-run | Relative cost |
|---|---|---|---|
| Geometry | width, height, margin, top, font-size, adding/removing nodes | Style → Layout → Paint → Composite | High |
| Appearance only | color, background, box-shadow, visibility | Style → Paint → Composite | Medium |
| Compositor-only | transform, opacity (on its own layer) | Composite | Low |
| Reading geometry after a write | offsetHeight, getBoundingClientRect, scrollTop | Forces a layout now | High inside loops |
Visual diagram
The browser's render pipeline for a hotel search page. Start the walkthrough.
Analogy
- Making a magazineThe editor's outline of the articlesRendering a pageThe DOM
- Making a magazineThe style guide, filed and indexedRendering a pageThe CSSOM
- Making a magazineA designer placing every blockRendering a pageLayout (reflow when redone)
- Making a magazineInking each blockRendering a pagePaint (repaint when redone)
- Making a magazineSliding a transparent sheet sidewaysRendering a pageCompositing: cheap transform moves
- Making a magazineAsking page height after every editRendering a pageLayout thrashing
Where the analogy breaks: a magazine is laid out once. A browser may redo layout and paint dozens of times per second while you scroll, so small costs add up fast.
Check your understanding
- 1.Composite layers into the frame on screen
- 2.Combine them into the render tree (computed styles)
- 3.Paint: draw pixels into layers
- 4.Parse HTML into the DOM and CSS into the CSSOM
- 5.Layout: compute size and position of every box
Practice ladder
Which stages does each change run?
Scenario Warm-upWhen a guest taps "Show map", StayHub's code does four things:
filterDrawer.style.transform = "translateX(-100%)"; // 1. slide the drawer out mapButton.style.backgroundColor = "#0a6"; // 2. highlight the button mapPanel.style.height = "400px"; // 3. open the map panel const h = resultsList.offsetHeight; // 4. read the list's height
For each line, say which pipeline stages the browser has to run (layout, paint, composite), and explain what's special about line 4.
Finish Lina's note on the photo gallery
Debugging PracticeMaya: "Swiping through hotel photos is jerky on my phone." The gallery code:
function showPhoto(i: number) {
for (const img of photos) {
img.style.left = (img.offsetLeft - i * 360) + "px";
}
}The performance panel shows 12 layouts per swipe, 70 ms in total. Lina's note has two gaps. Fill in steps 3 and 5.
- Reproduce on a slow CPU: done, 70 ms per swipe frame.
- Find the biggest slice: layout, 12 times per swipe.
- Name the cause: ___
- Batch reads, then writes.
- Change where the work starts: ___
- Measure again.
The checkout page shows a white screen
Debugging PracticeMaya: "On slow 4G, checkout shows a white screen for about 2.5 s, then everything appears at once." The checkout page's <head>:
<link rel="stylesheet" href="/app.css"> <!-- 40 KB --> <link rel="stylesheet" href="/admin-tools.css"> <!-- 180 KB, only used by staff --> <script src="/payment-sdk.js"></script> <!-- 250 KB, needed only when the guest clicks Pay --> <script src="/analytics.js"></script> <!-- 90 KB -->
The card images below also have no width or height. Explain what delays the first paint and propose fixes, ordered by impact.
1,000 pins on the hotel map
Decision StretchMaya wants a map next to the search results showing every hotel in the city as a pin: up to 1,000 pins in Paris. Pins move together when the guest drags or zooms the map.
Two options: - A: each pin is a DOM element (<div class="pin">) with its price label, positioned with CSS. - B: draw all pins on one <canvas>, and handle clicks by computing which pin is under the finger.
Write a short decision in this form: "We chose X over Y because…, accepting the risk of…".
Explain it yourself
Explain how a browser gets from an HTML file to pixels, and why animating transform is smoother than animating left.