completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

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.

By the end, you can
  • 01
    Read the work after a handlerFind the style and layout a click causes in a trace, with the elements and layout objects it touched.
  • 02
    Change less on each clickMove only what changed, keep only the rows on screen, and put a class on the element that changes.
  • 03
    Let 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.

Real frames of Asha's screen when she clicked Sort by price. As it is: the handler ended 42.8 ms after her click, but the screen still showed the old order until 269.7 ms, when the sorted list appeared. Windowed list: the handler ended at 3.7 ms, and the sorted list appeared at 42.0 ms.As it isher clickThe tea list in the shop's order: Mint tea, Green tea, Earl Grey, Chamomileold orderhandler: 42.8 msno new frame269.7 msThe tea list sorted by price: Chamomile, Rooibos, Earl Grey, Green teasorted by priceWindowed listher clickThe tea list in the shop's order: Mint tea, Green tea, Earl Grey, Chamomileold orderhandler: 3.7 msno new frame42.0 msThe tea list sorted by price: Chamomile, Rooibos, Earl Grey, Green teasorted by price
Real frames from the course's recording on Asha's phone, in ms after her click. On both pages the handler was done early. On the long list as it is, no new frame came until 269.7 ms.

Here are the same times as code:

When Asha saw the sorted listPop out in the code editor (opens in a new tab)JavaScript
// 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. handlerEnded is when the click's handler finished. listShown is 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 prints Windowed 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 in three partsPop out in the code editor (opens in a new tab)JavaScript
// 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. processingStart is when the handler started. processingEnd is when it ended. nextFrame is when the next frame was ready.
  • Lines 4 to 6 print Input delay: 1.9 ms, Processing: 40.9 ms and Presentation 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.

Real-life analogyA stage crew between scenes

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
The main idea

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:

  1. Move only the rows that change.
  2. Let the browser skip rows far from the screen.
  3. Keep only the rows on screen in the page.
  4. 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:

shop.js: the long list as it isJavaScript
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. byPrice holds 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:

How much the browser redidPop out in the code editor (opens in a new tab)JavaScript
// 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 prints Layout: 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:

The as-is click on Sort by price, to scale, 268.6 ms in all. Event Timing: input delay 1.9 ms, processing 40.9 ms, presentation delay 225.8 ms. The main thread, from the trace: the handler, then Recalculate style for 34.6 ms, Layout for 119.7 ms, Pre-paint for 26.7 ms, then 34.4 ms with no rendering steps in the trace, then Paint for 6.0 ms and the commit. The next frame came at 268.6 ms.Event Timing: the three partsprocessingpresentation delay 225.8 msThe main thread, from the tracehandlerstylelayout 119.7 mspre-painta gappaintnext frame: 268.6 msher click100 ms200 ms
The as-is click, to scale, from the course's trace. Style, layout and pre-paint ran right after the handler. Paint came 34.4 ms later, after a gap with no rendering steps in the trace.

And here is where the presentation delay went:

Where the presentation delay wentPop out in the code editor (opens in a new tab)JavaScript
// 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. prePaint gets ready for paint. layerizeAndCommit splits the drawing into layers and hands them to the compositor thread, as lesson 6.3 showed. hitTests is Chrome finding which element is under the mouse.
  • Line 5 prints Main-thread work: 190.2 ms of 225.8 ms. Line 6 prints Everything 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:

shop.js: move only the rows that changeJavaScript
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. i is 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.

Real-life analogyReshelving only the books that are out of place

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
Every row, or only the changesPop out in the code editor (opens in a new tab)JavaScript
// 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 prints Move 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.

Playground: six versions of Sort by price
After the handlerAs it is
  1. Recalculate style34.6 ms12,001 elements affected
  2. Layout119.7 ms21,005 of 21,034 layout objects
  3. Pre-paint26.7 ms
  4. Paint6.0 ms
  5. Layerize and commit2.2 ms
  6. Checks in between1.0 mshit tests
  7. Everything else35.6 msno rendering work in the trace
Try it yourself
The long list as it is: the click took 268.6 ms

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.

From the course's recording in Chrome 157, with the CPU slowed 4 times: the middle of three clicks for each version. The steps come from Chrome's trace, from the end of the handler to the frame.

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:

styles: skip rows far from the screenCSS
.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. auto tells the browser to remember the real size once it has drawn the row.
Real-life analogyA stockroom of labelled boxes

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:

The long list with content-visibility: auto on every row, after Asha's click. 6 rows were on her screen, and Chrome drew them. It also drew 14 more rows just below the screen. It skipped the insides of the other 2,980 rows, and kept each as an empty box of its stand-in size.screenChamomileRooibosEarl GreyGreen teaJasmineMint teaon screen: 6 rows, drawnMasala chaiOolongand more, down to row 20near the screen: 14 more, drawnthe other 2,980: skippedempty boxes, 32 px stand-in
After the click, Chrome drew 20 of the 3,000 rows: the 6 on screen and 14 just below. The other 2,980 stayed empty boxes.
Moving every row, with and without skippingPop out in the code editor (opens in a new tab)JavaScript
// 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 prints Skip 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:

shop.js on the windowed page, shortenedJavaScript
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_HEIGHT is 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.
Real-life analogyA station departure board

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 long list against the windowed listPop out in the code editor (opens in a new tab)JavaScript
// 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 ms and Windowed 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:

styles: a class on the bodyCSS
.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:

styles: a class on the buttonCSS
#sort.on { background: #1f6f4a; color: #fff; }
  • Line 1 gives the button a green background and white text when it has the class on.
Real-life analogyA bell for the whole café, or a word with one waiter

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.

Real frames of Asha's screen when she clicked Sort by price on two pages. Both showed the same new screen: the list sorted and the Sort button green. Class on the body: 3,020 elements restyled in 33.3 ms, and the frame came at 114.1 ms. Class on the button: 21 elements restyled in 3.2 ms, and the frame came at 74.4 ms.On the bodyher clickThe tea list in the shop's order, with a white Sort by price buttonwhite button3,020 restyledstyle: 33.3 ms114.1 msThe list sorted by price, with a green Sort by price buttongreen, sortedOn the buttonher clickThe tea list in the shop's order, with a white Sort by price buttonwhite button21 restyledstyle: 3.2 ms74.4 msThe list sorted by price, with a green Sort by price buttongreen, sorted
Real frames from the recording, in ms after Asha's click. Both pages drew the same green button. The class on the body made Chrome restyle 3,020 elements to do it.
The same green button, two waysPop out in the code editor (opens in a new tab)JavaScript
// 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 ms and Class 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:

Playground: sort a long list four ways
What Sort does, shortenedJavaScript
// 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");
Move every row0 timed
      Try it yourself
      Which mode draws least?

      Pick a mode, then press Sort a few times. Your browser times each click, and splits off the presentation delay.

      Real clicks in your own browser, timed with Event Timing and rounded to 8 ms. The list has 2,000 rows. Each Sort switches between the shop's order and price order. Numbers change from run to run, and with your computer's speed.

      Less work, more work, or moved work?

      Read this table across. Each row is one fix: what it changes, and what the recording showed.

      Four ways to draw less after a click
      The fixWhat it changesWhat the recording showed
      Move only the rows that changeThe 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 rowRows 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 screenThe page holds a dozen rows, so there is little to redraw.The click took 41.2 ms.
      Put the class on the buttonOnly 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?

      Less work, more work, or moved work?
      • 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
      Try it yourself
      0 of 8 correct

      Sort each change by what it does to the work before the next frame.

      Choose a category for every card. You can change an answer at any time; Reset clears them all.

      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.

      Exercise 1 · Warm-upWhich steps would the insight flag?

      This program applies the Optimize DOM size rule to four recorded steps. What does it print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      // 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}`);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 2 · Warm-upHow many moves?

        This is the move-only-changes loop on four names. What does it print?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        // 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}`);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 3 · PracticeLayout on the windowed list

          In the six-versions playground, pick Windowed list. How many layout objects needed layout?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 4 · PracticePre-paint with skipping

            In the playground, pick Skip off-screen rows. How long did Pre-paint take, in ms?

            Answer, then press Check. Spacing and letter case don’t matter.

              Exercise 5 · ChallengeThe insight in DevTools

              Open the course's trace of the as-is click in Chrome's DevTools. What DOM depth does the Optimize DOM size insight show?

              Answer, then press Check. Spacing and letter case don’t matter.

                Exercise 6 · ChallengeChrome's margin

                In Chrome's display lock code, what margin around the screen, in percent, decides which rows are near it?

                Answer, then press Check. Spacing and letter case don’t matter.

                  Check your understanding

                  For each question, ask how many elements the browser had to redo before the next frame.

                  The presentation delay quiz · 7 questionsScore: first tries count
                  1. 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.

                  2. 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.

                  3. Question 3 of 7A row has content-visibility: auto and sits far below the screen. What does the browser do with it?

                    Choose an answer to see the explanation.

                  4. Question 4 of 7On a row with content-visibility: auto, what is contain-intrinsic-size for?

                    Choose an answer to see the explanation.

                  5. 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.

                  6. 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.

                  7. Question 7 of 7What does a windowed list give up that content-visibility: auto keeps?

                    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:

                  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.

                  CompleteFrontend Clear concepts. Working examples.