Reducing presentation delay
Make the frame after a click come sooner: change fewer elements, skip off-screen rows with content-visibility, keep only visible rows, and scope style changes.
- 01Read the work after a handlerFind the style and layout a click causes in a trace, with the elements and layout objects it touched.
- 02Change less on each clickMove only what changed, keep only the rows on screen, and put a class on the element that changes.
- 03Let the browser skip what nobody seesUse content-visibility and contain-intrinsic-size on long lists, and know what they cost.
Why does the sorted list still come late?
Asha is back on the long tea list from lesson 26.2. It has 3,000 rows: the eight teas on top, and 2,992 house blends below them. She clicks Sort by price. For a quarter of a second, nothing changes. Then the teas jump into price order.
Ravi already made the Sort handler quick. A handler is the code that runs when an event, like a click, happens. Ravi's only moves the rows. So why does the new order still come so late? And how can he make it come sooner?
Here is what Asha saw. These are real frames Chrome captured. A frame is one picture the browser draws on the screen. The second row is a faster version of the same page, which this lesson builds.
Here are the same times as code:
// When Asha saw the sorted list, in ms after her click, on her phone.const pages = [ { name: "As it is", handlerEnded: 42.8, listShown: 269.7 }, { name: "Windowed list", handlerEnded: 3.7, listShown: 42.0 },];for (const page of pages) { console.log(`${page.name}: the handler ended at ${page.handlerEnded.toFixed(1)} ms; the sorted list showed at ${page.listShown.toFixed(1)} ms`);}- Line 1 is a comment. Times are in milliseconds (ms) after her click. A millisecond is a thousandth of a second.
- Lines 3 and 4 hold the two pages.
handlerEndedis when the click's handler finished.listShownis the first frame with the sorted list. - Line 7 prints
As it is: the handler ended at 42.8 ms; the sorted list showed at 269.7 ms. Then it printsWindowed list: the handler ended at 3.7 ms; the sorted list showed at 42.0 ms.
On the first page, the handler was done after 42.8 ms. But the sorted list came at 269.7 ms. So most of the wait came after the handler. Why it matters: a faster handler can't fix this. The slow part comes later.
Where the time went
Lesson 26.2 split a click into three parts. Here they are for this click:
// The as-is click, from Chrome's trace. Times in ms after Asha's click.const click = { processingStart: 1.9, processingEnd: 42.8, nextFrame: 268.6 }; console.log(`Input delay: ${click.processingStart.toFixed(1)} ms`);console.log(`Processing: ${(click.processingEnd - click.processingStart).toFixed(1)} ms`);console.log(`Presentation delay: ${(click.nextFrame - click.processingEnd).toFixed(1)} ms`);- Line 2 holds three times from Chrome's trace: a detailed record of everything Chrome did.
processingStartis when the handler started.processingEndis when it ended.nextFrameis when the next frame was ready. - Lines 4 to 6 print
Input delay: 1.9 ms,Processing: 40.9 msandPresentation delay: 225.8 ms. Input delay is the wait before the handler starts. Processing is the handler running.
Presentation delay runs from the end of the handlers until the next frame is on the screen. Here it was 225.8 ms of the whole 268.6 ms. In lesson 26.2, the long list's tap spent 293.5 ms there. Why it matters: on a page this big, the drawing is the slow part, not the code.
At a play, the director calls out: "Swap the sofa and the chair." That takes two seconds. Then the crew moves them. If they clear the whole stage and set it up again, the audience waits a long time. The curtain only goes up when the stage is ready.
- In real life: The director's call: "Swap the sofa and the chair"
- In the browser: The handler:
teas.append(...byPrice) - In real life: The crew moving furniture and checking the props
- In the browser: Style and layout after the handler
- In real life: The curtain going up
- In the browser: The next frame on the screen
- In real life: Moving only the two pieces that changed
- In the browser: Changing fewer elements
- In real life: Props behind the scenery, which nobody sees
- In the browser: Rows far below the screen
After a handler changes the page, the browser works out styles and layout for what changed, then paints. That work is the presentation delay. Change fewer elements, and let the browser skip what nobody can see. This lesson tries four ways on the same long list, and times each one.
Lesson 6.3, The pixel pipeline, walked through these steps. This lesson makes them smaller, in four ways:
- Move only the rows that change.
- Let the browser skip rows far from the screen.
- Keep only the rows on screen in the page.
- Put a class on the element that changes, not on the whole page.
How the course recorded it
- Six pages: each is lesson 26.2's long list of 3,000 rows, with the house blends listed cheapest first. Each page updates the list in its own way when Asha clicks Sort by price.
- Asha's phone: this computer in Chrome 157, with its processor, the CPU, slowed 4 times, as in lessons 26.2 and 28.1. Her window was 412 by 480 pixels.
- Three clicks each: each page loaded three times, taking turns. The lesson quotes the middle click. Each time, Chrome kept a trace, with screenshots.
- A mouse click: the click went through Chrome's DevTools protocol, the way programs drive Chrome. Chrome passes a mouse click straight to the page, as it does a real mouse.
- Exact times: the trace keeps each click's times unrounded. A page's own Event Timing entries round the duration to 8 ms. So the web-vitals library, version 6.2.3, reported this click as 272 ms.
What the browser redoes after the handler
Here is the Sort handler on the first page. It's lesson 26.2's quick handler:
const byPrice = [...teas.children].sort((a, b) => a.dataset.price - b.dataset.price);document.querySelector("#sort").addEventListener("click", function sortByPrice() { teas.append(...byPrice);});- Line 1 sorts the rows by price once, when the page starts.
byPriceholds all 3,000 rows, cheapest first. - Line 2 adds a click listener to the Sort by price button.
- Line 3 is the whole handler.
teas.append()moves each row to the end of the list, in price order.
Line 3 looks small. But append() takes each row out of the list and puts it back. It does that to all 3,000 rows. That includes the 2,992 blends that end up where they were. To the browser, every row is new in its place.
The DOM is the page's tree of elements. web.dev's guide to style says "adding and removing elements" makes the browser work out styles again. Often it has to lay out part or all of the page too. Those are the first two steps after a handler:
- Style, which DevTools calls Recalculate style: working out which CSS rules apply to each element.
- Layout: working out each element's size and place. Chrome keeps a box for each element it shows, and for each piece of text. These boxes are called layout objects.
DevTools shows how much of the page each step touched. Click a Recalculate style block in its Performance panel, and it shows Elements affected. Click a Layout block, and it shows Nodes that need layout. Here are both, from the trace:
// The as-is click, from Chrome's trace: how much of the page the browser redid after the handler.const page = { elements: 12025 };const style = { ms: 34.6, elementsAffected: 12001 };const layout = { ms: 119.7, needLayout: 21005, layoutObjects: 21034 };console.log(`Recalculate style: ${style.ms} ms, ${style.elementsAffected} of ${page.elements} elements`);console.log(`Layout: ${layout.ms} ms, ${layout.needLayout} of ${layout.layoutObjects} layout objects`);- Line 2 holds the page's size: 12,025 elements.
- Lines 3 and 4 hold the two steps, with the counts DevTools shows.
- Line 5 prints
Recalculate style: 34.6 ms, 12001 of 12025 elements. Line 6 printsLayout: 119.7 ms, 21005 of 21034 layout objects.
Chrome restyled almost every element on the page. It laid out almost every box. Yet only the eight teas at the top changed order. Why it matters: the browser's work follows what your code changed, not what the visitor sees change.
Here is the whole click, to scale:
And here is where the presentation delay went:
// The as-is click's presentation delay, from Chrome's trace. Times in ms.const steps = { style: 34.6, layout: 119.7, prePaint: 26.7, paint: 6.0, layerizeAndCommit: 2.2, hitTests: 1.0 };const presentationDelay = 225.8;const work = Object.values(steps).reduce((sum, ms) => sum + ms, 0);console.log(`Main-thread work: ${work.toFixed(1)} ms of ${presentationDelay} ms`);console.log(`Everything else: ${(presentationDelay - work).toFixed(1)} ms`);- Line 2 holds each step's time.
prePaintgets ready for paint.layerizeAndCommitsplits the drawing into layers and hands them to the compositor thread, as lesson 6.3 showed.hitTestsis Chrome finding which element is under the mouse. - Line 5 prints
Main-thread work: 190.2 ms of 225.8 ms. Line 6 printsEverything else: 35.6 ms.
Most of the presentation delay was work on the main thread. The main thread is the one thread that runs the page's JavaScript and draws its frames. Layout alone took more than half of it.
The other 35.6 ms had no rendering steps in the trace. Every version had a part like it, about 34 to 36 ms. Even the windowed list, with almost nothing to redraw, had a presentation delay of 37.5 ms. Lesson 28.1 saw about 38 ms after every click. That part didn't grow with the page, so this lesson leaves it alone. Why it matters: on a big page, the part you can shrink is style and layout.
Move only the rows that change
Only the eight teas at the top are out of order. The 2,992 blends are already in price order. So the second page moves only the rows that are out of place:
const byPrice = [...teas.children].sort((a, b) => a.dataset.price - b.dataset.price);document.querySelector("#sort").addEventListener("click", function sortByPrice() { byPrice.forEach((row, i) => { if (teas.children[i] !== row) teas.insertBefore(row, teas.children[i]); });});- Line 1 is the same as before: the rows in price order, worked out once.
- Line 3 goes through them, cheapest first.
iis the place each row should be in. - Line 4 looks at the row in that place now. If it's a different row,
insertBefore()moves the right row there. If not, nothing happens.
For the eight teas, line 4 moves five rows. For the blends, it moves none.
A librarian finds five books in the wrong place on a long shelf. She moves those five. She doesn't take every book off and put it back in order. That would take all afternoon, and the shelf would look the same at the end.
- In real life: The books on a long shelf
- In the browser: The 3,000 rows in the list
- In real life: Taking every book off and putting it back
- In the browser:
teas.append(...byPrice) - In real life: Moving the five strays
- In the browser:
insertBefore()on five rows - In real life: A shelf that looks the same either way
- In the browser: The same sorted list on screen
// Two clicks on Sort by price, from Chrome's trace. Times in ms.const pages = [ { name: "As it is", rowsMoved: 3000, restyled: 12001, layout: 119.7, click: 268.6 }, { name: "Move only changes", rowsMoved: 5, restyled: 21, layout: 20.6, click: 76.3 },];for (const page of pages) { console.log(`${page.name}: ${page.rowsMoved} rows moved, ${page.restyled} elements restyled, layout ${page.layout.toFixed(1)} ms, click ${page.click.toFixed(1)} ms`);}- Lines 3 and 4 hold each page's click: rows moved, elements restyled, layout time, and the whole click.
- Line 7 prints
As it is: 3000 rows moved, 12001 elements restyled, layout 119.7 ms, click 268.6 ms. Then it printsMove only changes: 5 rows moved, 21 elements restyled, layout 20.6 ms, click 76.3 ms.
Asha saw the same sorted list. But Chrome restyled 21 elements instead of 12,001. The click took 76.3 ms. That's well under 200 ms, the limit for a good INP. INP, Interaction to Next Paint, is roughly a visit's slowest click, tap or key press, as lesson 26.1 showed. Why it matters: the same screen can cost very different amounts of work.
Look at layout, though. Only 40 layout objects needed layout, yet layout still took 20.6 ms. web.dev's layout guide says why: "Layout is almost always scoped to the entire document." A page with thousands of boxes pays for that, even for a small change.
How big is too big?
To count a page's elements, web.dev suggests running document.querySelectorAll("*").length in the console. The long list has 12,025. The performance monitor from lesson 7.5 tracks DOM nodes, which count text too.
But size alone isn't the problem. Chrome's DevTools has an insight called Optimize DOM size. An insight is a tip DevTools gives about a trace, as lesson 7.3 showed. It flags a step only when it was slow and big:
- a Recalculate style that took 40 ms or more and affected more than 300 elements;
- a Layout that took 40 ms or more, with more than 100 layout objects.
The first exercise asks which of this lesson's steps it would flag. Why it matters: a big page costs time when a change makes the browser redo much of it.
Pick a page below to see every step the browser took after the handler. The next sections explain the other pages.
- Recalculate style
34.6 ms12,001 elements affected - Layout
119.7 ms21,005 of 21,034 layout objects - Pre-paint
26.7 ms - Paint
6.0 ms - Layerize and commit
2.2 ms - Checks in between
1.0 mshit tests - Everything else
35.6 msno rendering work in the trace
Input delay 1.9 ms, processing 40.9 ms, presentation delay 225.8 ms. The page had 12,025 elements. The click added 3,000 rows to the list, and Chrome then drew 3,000 rows.
Let the browser skip rows nobody can see
Asha's window shows six rows, the last one cut off. The other 2,994 are below the screen. Yet the browser styled and laid out all of them. What if it could skip the rows nobody can see?
The CSS property content-visibility does that. The third page keeps the as-is handler and adds one rule to its stylesheet:
.tea-list li { content-visibility: auto; contain-intrinsic-size: auto 32px;}- Line 1 picks every row of the tea list.
- Line 2 is
content-visibility: auto. When a row is far from the screen, the browser keeps it as an empty box. It skips the row's insides: no style, no layout, no paint. When the row comes near the screen, the browser draws it. - Line 3 gives each skipped row a stand-in size. 32 px is about the height of what's inside a row: its Add button.
autotells the browser to remember the real size once it has drawn the row.
Meera's shop has a front room and a stockroom. She sets out the shelves customers can see. The boxes in the stockroom stay shut. Each has its size written on it, so she knows how much room it takes. She unpacks a box just before it goes on a shelf.
- In real life: The shelves customers can see
- In the browser: The rows near the screen, drawn as usual
- In real life: Shut boxes in the stockroom
- In the browser: Rows whose insides are skipped
- In real life: The size written on each box
- In the browser:
contain-intrinsic-size - In real life: Unpacking a box before it goes on a shelf
- In the browser: Drawing a row as it comes near the screen
Without line 3, a skipped row would be laid out as if it were empty, much shorter than a real row. The page would jump as rows were drawn.
content-visibility comes from a spec, CSS Containment. It says an element with auto skips its contents when it isn't relevant to the user. Relevant means near the screen, focused, or selected. "Near" includes a margin around the screen, so rows get drawn just before they scroll into view. The spec suggests a margin. Chrome picks its own, and the exercises ask you to find it.
Here is what Chrome drew after Asha's click:
// Moving all 3,000 rows, with and without content-visibility, from Chrome's trace. Times in ms.const pages = [ { name: "As it is", rowsDrawn: 3000, restyled: 12001, laidOut: 21005, presentationDelay: 225.8 }, { name: "Skip off-screen rows", rowsDrawn: 20, restyled: 3081, laidOut: 3150, presentationDelay: 152.8 },];for (const page of pages) { console.log(`${page.name}: ${page.rowsDrawn} rows drawn, ${page.restyled} elements restyled, ${page.laidOut} layout objects, presentation delay ${page.presentationDelay.toFixed(1)} ms`);}- Lines 3 and 4 hold both pages: rows drawn, elements restyled, layout objects, and the presentation delay.
- Line 7 prints
As it is: 3000 rows drawn, 12001 elements restyled, 21005 layout objects, presentation delay 225.8 ms. Then it printsSkip off-screen rows: 20 rows drawn, 3081 elements restyled, 3150 layout objects, presentation delay 152.8 ms.
The presentation delay fell from 225.8 ms to 152.8 ms. Chrome drew 20 rows instead of 3,000. Why it matters: one CSS rule cut the work after the click by about a third.
Why it didn't do more
The whole click still took 204.5 ms, just over the 200 ms limit. Three things still cost time:
- The handler still moved all 3,000 rows. Each row was still a box to style and place.
- Chrome had to check which rows were near the screen. The trace shows four checks named
IntersectionObserverController::computeIntersections, 22.8 ms in all. The page has no observer of its own, so these are Chrome's. - Then it styled and laid out the 20 rows near the screen, in a second round.
The spec asks for this. When a row is taken out and put back, the browser must work out again if it's near the screen. And it must do that in the same frame. Here every row had moved, so every row needed the check. Why it matters: content-visibility skips the insides of rows, but not the rows themselves.
What to watch
- Skipped rows stay in the page. The spec says find-in-page, the keyboard's tab order and selecting text still reach them.
- Reading a size undoes the skip. web.dev warns that the browser can only skip work if you don't call DOM APIs that force it, like reading a size (lesson 26.2).
- Use it on small parts, like each row. The spec's own example: on a whole timeline, it would skip nothing, because the timeline is always on screen.
Keep only the rows on screen
The fourth page goes further. It doesn't put 3,000 rows in the page at all. The teas come as data. Only a dozen rows are in the page: the ones on screen, and a few more. This is called a windowed list, or a virtual list. Here are the lines that draw it:
function draw() { const first = Math.max(0, Math.floor((scrollY - teas.offsetTop) / ROW_HEIGHT)); const shown = order.slice(first, first + 12); teas.replaceChildren(...shown.map((tea, i) => rowFor(tea, first + i)));}addEventListener("scroll", draw);document.querySelector("#sort").addEventListener("click", function sortByPrice() { order = byPrice; draw();});- Lines 1 to 5 are
draw(). Line 2 works out the first row on screen, from how far Asha has scrolled.ROW_HEIGHTis 52.4 px: a row and the 6 px gap under it. - Line 3 takes 12 teas from there. Line 4 swaps the rows in the page for those 12.
rowFor()builds one row, at the place it would have in the full list. - Line 6 draws again on every scroll.
- Lines 7 to 10 are the handler. Line 8 puts the teas in price order, and line 9 draws.
A big station has hundreds of trains a day. The board shows only the next twelve. When a train leaves, the lines move up and a new one appears. Nobody prints the whole day's timetable on the board.
- In real life: The whole day's timetable
- In the browser: All 3,000 teas, kept as data
- In real life: The twelve lines on the board
- In the browser: The 12 rows in the page
- In real life: Lines moving up as trains leave
- In the browser:
draw()on every scroll - In real life: A new timetable
- In the browser: Sort by price
// The whole page's elements, and the click on Sort by price, from Chrome's trace. Times in ms.const pages = [ { name: "As it is", elements: 12025, restyled: 12001, click: 268.6 }, { name: "Windowed list", elements: 74, restyled: 48, click: 41.2 },];for (const page of pages) { console.log(`${page.name}: ${page.elements} elements on the page, ${page.restyled} restyled, click ${page.click.toFixed(1)} ms`);}- Lines 3 and 4 hold both pages: the elements in the page, the elements restyled, and the click.
- Line 7 prints
As it is: 12025 elements on the page, 12001 restyled, click 268.6 msandWindowed list: 74 elements on the page, 48 restyled, click 41.2 ms.
The page had 74 elements instead of 12,025. Chrome restyled 48, and the click took 41.2 ms, the quickest of all six. Why it matters: the cheapest elements to redraw are the ones that aren't in the page.
Read first, then write
Line 2 reads teas.offsetTop, a size. Lesson 26.2 showed that reading a size right after changing the page forces layout inside the handler. draw() reads before it writes, so Chrome can answer from the last frame's layout. That's web.dev's advice: "batch your style reads and do them first", then write. The trace agrees: no layout ran inside this handler.
What a windowed list costs
Rows that aren't in the page aren't anywhere for the browser. Find-in-page can't find them, and the keyboard can't reach them. content-visibility: auto keeps them in the page. The CSS Containment spec even says it can be used "instead of complicated "virtual list" techniques, at least in many cases."
For React, Chrome's DOM size guide points to windowing libraries like react-window. The next lesson, in Responsiveness: INP, looks at large lists in frameworks. Why it matters: a windowed list was the fastest here. content-visibility is the simplest. Pick by what the page needs.
Put the class where the change is
Ravi wants one more thing. After a sort, the Sort by price button should turn green, so Asha knows the list is sorted. The last two pages both start from the page that moves only the changes. Both add a green button. They differ in where the class goes.
The fifth page keeps its colours in CSS variables. A CSS variable is a named value, like --sort-bg, that rules read with var(). Its handler ends with document.body.classList.add("by-price");. Here are its two new rules:
.by-price { --sort-bg: #1f6f4a; --sort-ink: #fff; }#sort { background: var(--sort-bg, #fff); color: var(--sort-ink, #1f6f4a); }- Line 1: when the body has the class
by-price, it sets two variables, a green background and white text. - Line 2: the Sort button uses them. Until they are set, it falls back to white with green text.
The sixth page puts a class on the button itself. Its handler ends with document.querySelector("#sort").classList.add("on");. Here is its one new rule:
#sort.on { background: #1f6f4a; color: #fff; }- Line 1 gives the button a green background and white text when it has the class
on.
Meera wants Ravi to start taking dessert orders. She could ring the bell and tell the whole café. Every waiter and every diner would stop to listen, in case it's about them. Or she could just tell Ravi.
- In real life: Ringing the bell for everyone
- In the browser: A class on the body
- In real life: Everyone stops to listen, in case it's about them
- In the browser: Chrome restyles every element the variables reach
- In real life: A quiet word with Ravi
- In the browser: A class on the button
- In real life: Ravi starts taking dessert orders
- In the browser: The button turns green
Why does a class on the body reach so far? MDN says these variables "inherit their value from their parent." So the new values reach every element inside the body. The browser has to work out styles again for thousands of them.
// The same green button, two ways, from Chrome's trace. Times in ms.const pages = [ { name: "Class on the body", restyled: 3020, style: 33.3, click: 113.3 }, { name: "Class on the button", restyled: 21, style: 3.2, click: 73.6 },];for (const page of pages) { console.log(`${page.name}: ${page.restyled} elements restyled in ${page.style.toFixed(1)} ms, click ${page.click.toFixed(1)} ms`);}- Lines 3 and 4 hold both pages: elements restyled, the time for style, and the whole click.
- Line 7 prints
Class on the body: 3020 elements restyled in 33.3 ms, click 113.3 msandClass on the button: 21 elements restyled in 3.2 ms, click 73.6 ms.
The same green button cost 3,020 restyled elements one way, and 21 the other. web.dev's style guide says the work a change causes often matters more than how complex your selectors are. Its advice is to "reduce the number of invalidated elements". Why it matters: put a class on the element that changes, or as close to it as you can.
Now try it in your own browser. The list below has 2,000 rows. Pick a mode, press Sort a few times, and watch the presentation delay:
// What Sort does in each mode, shortened.if (mode === "Move only changes" || mode === "Changes, plus a class on the list") { next.forEach((row, i) => { if (list.children[i] !== row) list.insertBefore(row, list.children[i]); });} else { list.append(...next); // with content-visibility: auto on every row in the second mode}if (mode === "Changes, plus a class on the list") panel.classList.toggle("by-price");Pick a mode, then press Sort a few times. Your browser times each click, and splits off the presentation delay.
Less work, more work, or moved work?
Read this table across. Each row is one fix: what it changes, and what the recording showed.
| The fix | What it changes | What the recording showed |
|---|---|---|
| Move only the rows that change | The browser restyles and lays out the moved rows, not all of them. | The click took 76.3 ms, not 268.6 ms. |
| content-visibility: auto on each row | Rows far from the screen are kept as empty boxes of a stand-in size. | Presentation delay fell from 225.8 ms to 152.8 ms. |
| Keep only the rows on screen | The page holds a dozen rows, so there is little to redraw. | The click took 41.2 ms. |
| Put the class on the button | Only the button's style changes, not the whole page's. | 21 elements restyled, not 3,020. |
Now sort these changes. Does each one give the browser less work before the next frame, more, or the same work somewhere else?
- Move only the rows whose place changed
- Give each row content-visibility: auto
- Keep only the rows near the screen in the page
- Put the on class on the Sort button itself
- Rebuild all 3,000 rows with innerHTML on every sort
- Add a class to the body that sets colour variables
- Read teas.offsetHeight right after moving the rows
- Move all 3,000 rows in a requestAnimationFrame callback
Sort each change by what it does to the work before the next frame.
Common mix-ups
- "If the handler is quick, the click is quick." No. On the long list as it is, the handler ended at 42.8 ms. The next frame came at 268.6 ms.
- "append() only costs something for rows that change place." No. It takes out and puts back every row it gets. Chrome restyled 12,001 elements to reorder eight teas.
- "content-visibility takes rows out of the page." No. Skipped rows stay in the DOM. Find-in-page and the keyboard still reach them.
- "content-visibility on the whole list is enough." No. The list is always on screen, so it would never be skipped. Put it on each row.
- "A small change means a small layout." No. Moving five rows still took 20.6 ms of layout, on a page with 21,034 layout objects.
- "If the screen looks the same, it doesn't matter where the class goes." No. The same green button cost 3,020 restyled elements from the body, and 21 from the button.
Practice exercises
Answer first, then use the hints.
This program applies the Optimize DOM size rule to four recorded steps. What does it print?
// DevTools' Optimize DOM size insight: a style or layout step is large if it took 40 ms or more,
// and touched over 300 elements (style) or over 100 layout objects (layout).
const steps = [
{ page: "As it is", name: "Recalculate style", ms: 34.6, elements: 12001 },
{ page: "As it is", name: "Layout", ms: 119.7, objects: 21005 },
{ page: "Class on the body", name: "Recalculate style", ms: 33.3, elements: 3020 },
{ page: "Move only changes", name: "Layout", ms: 20.6, objects: 40 },
];
const large = (step) => step.ms >= 40 && (step.name === "Layout" ? step.objects > 100 : step.elements > 300);
for (const step of steps.filter(large)) console.log(`${step.page}: ${step.name}`);As it is: Layout. It took 119.7 ms with 21,005 layout objects. The two style steps had thousands of elements, but each took under 40 ms.
This is the move-only-changes loop on four names. What does it print?
// The move-only-changes loop, on plain names instead of rows.
const list = ["Mint tea", "Green tea", "Earl Grey", "Chamomile"];
const byPrice = ["Chamomile", "Earl Grey", "Green tea", "Mint tea"];
let moves = 0;
byPrice.forEach((tea, i) => {
if (list[i] !== tea) {
list.splice(list.indexOf(tea), 1);
list.splice(i, 0, tea);
moves += 1;
}
});
console.log(`Moves: ${moves}`);Moves: 3. After three moves, every tea is in its place, so the last check moves nothing.
In the six-versions playground, pick Windowed list. How many layout objects needed layout?
89 of 118. The whole page had only 118 layout objects, and layout took 0.7 ms.
In the playground, pick Skip off-screen rows. How long did Pre-paint take, in ms?
15.5 ms, against 26.7 ms for the long list as it is. Only 20 rows had their insides drawn.
Open the course's trace of the as-is click in Chrome's DevTools. What DOM depth does the Optimize DOM size insight show?
5. The deepest element is a tea's name: inside a row, inside the list, inside main, inside the body. Deep nesting wasn't the problem here. The number of rows was.
In Chrome's display lock code, what margin around the screen, in percent, decides which rows are near it?
150. Chrome draws an element with content-visibility: auto once it comes within 150% of the window's size of the window. Asha's window was 480 px tall, so rows that started above 1,200 px from its top got drawn: 20 rows.
Check your understanding
For each question, ask how many elements the browser had to redo before the next frame.
Question 1 of 7On the long list as it is, which step took the longest after Ravi's handler ended?
Choose an answer to see the explanation.
Question 2 of 7Only the eight teas at the top changed order. Why did
teas.append(...byPrice)make Chrome restyle almost every element?Choose an answer to see the explanation.
Question 3 of 7A row has
content-visibility: autoand sits far below the screen. What does the browser do with it?Choose an answer to see the explanation.
Question 4 of 7On a row with
content-visibility: auto, what iscontain-intrinsic-sizefor?Choose an answer to see the explanation.
Question 5 of 7With content-visibility, the click was still just over the limit for a good INP. Why didn't it help more?
Choose an answer to see the explanation.
Question 6 of 7Ravi turns the Sort button green. Why did a class on the body restyle so many more elements than a class on the button?
Choose an answer to see the explanation.
Question 7 of 7What does a windowed list give up that
content-visibility: autokeeps?Choose an answer to see the explanation.
Key takeaways
- Presentation delay is the browser's own work after your handler: style, layout, pre-paint and paint.
- That work follows what your code changed. Moving 3,000 rows restyled 12,001 elements, though only the top eight teas changed order.
- DevTools shows the size of each step: Elements affected for style, Nodes that need layout for layout.
- Move only what changed. The same sorted list took 76.3 ms instead of 268.6 ms.
- content-visibility: auto with contain-intrinsic-size skips the insides of rows far from the screen, and keeps them findable.
- A windowed list keeps only the rows on screen in the page. It was the fastest, but the other rows aren't in the page.
- Put a class on the element that changes. A class on the body that sets variables restyles everything inside it.
Remember the one-liner.
The next frame waits for the browser to redo what you changed: change less, and let it skip what nobody sees.
Sources
The facts on this page come from these specs, docs and source files:
- Optimize Interaction to Next Paint, web.dev.
- Reduce the scope and complexity of style calculations, web.dev.
- Avoid large, complex layouts and layout thrashing, web.dev.
- TimelineUIUtils.ts, Chrome DevTools, ChromeDevTools on GitHub.
- DOM size and interactivity, web.dev.
- Optimize DOM size, Chrome for Developers.
- content-visibility: the new CSS property that boosts your rendering performance, web.dev.
- CSS Containment Module Level 2, W3C.
- CSS Box Sizing Module Level 4: contain-intrinsic-size, W3C.
- content-visibility, MDN.
- contain-intrinsic-size, MDN.
- display_lock_document_state.cc, Chromium, Chromium on GitHub.
- Using CSS custom properties (variables), MDN.
Coming next: INP in frameworks. Frameworks update the page for you. The next lesson looks at what that means for clicks, and at large lists in frameworks.