The three phases of an interaction
Split a slow tap into input delay, processing and presentation delay, see what makes each one long in Chrome, and learn which one to fix first.
- 01Split a slow interactionTurn an Event Timing entry, or web-vitals' attribution, into input delay, processing and presentation delay.
- 02Name the usual cause of eachTell a busy main thread, a heavy listener and a big redraw apart, and see each in long animation frames or a trace.
- 03Choose what to fix firstFind the biggest phase of the slowest interaction, and know which part of the course teaches its fix.
Where did the 304 ms go?
Asha is back on the Leaf and Kettle tea list. She taps Sort by price. For about a third of a second, nothing happens. Then the list jumps into its new order.
Lesson 26.1 timed that tap at 304 milliseconds (ms). It was the slowest moment of her visit. So it became her visit's INP, or Interaction to Next Paint. Good is 200 ms or less.
Ravi builds the shop's site. He wants that tap to be faster. But faster where? A tap isn't one job. It's a short chain of waits. If he speeds up the wrong one, nothing changes.
So his question is simple. Where did the 304 ms go?
Here is Asha's screen at four moments of that tap. These are real frames Chrome captured. A frame is one picture the browser draws on the screen.
The first three screens look the same. The list changed only at the last one. Between them are the three parts of the tap. Chrome wrote down the times that mark them. For each event, it keeps an entry: a small record of times, from lesson 11.4. Here are the times from the tap's entry, split with three subtractions:
// Chrome's entry for Asha's tap on Sort by price, from lesson 26.1. Times in ms.const tap = { startTime: 14822.8, processingStart: 14825.2, processingEnd: 15125.4, duration: 304 }; console.log(`Input delay: ${(tap.processingStart - tap.startTime).toFixed(1)} ms`);console.log(`Processing: ${(tap.processingEnd - tap.processingStart).toFixed(1)} ms`);console.log(`Presentation delay: ${(tap.startTime + tap.duration - tap.processingEnd).toFixed(1)} ms`);- Line 1 is a comment. Times are in ms, counted from when the page started loading.
- Line 2 holds four times from Chrome's entry for the tap's
clickevent.startTimeis the moment of the tap.processingStartis when the click's handlers were about to run. An event handler is the code that runs when an event happens.processingEndis when the handlers finished.durationruns from the tap to the next frame. - Line 4 subtracts the tap from the start of the handlers. It prints
Input delay: 2.4 ms. - Line 5 subtracts the handlers' start from their end. It prints
Processing: 300.2 ms. - Line 6 takes what's left until the next frame. It prints
Presentation delay: 1.4 ms.
So the answer is clear. Almost all of the 304 ms was processing. That was Ravi's own Sort handler, doing its 300 ms of work. Why it matters: Ravi now knows where to look. The fix is inside his handler.
These three parts have names. Lesson 11.4 met them as three waits at a café counter. This lesson goes further. For each part, it asks what makes it long, how to see it, and which to fix first.
- Input delay: from the tap until the first handler starts.
- Processing duration: while the handlers run. Lesson 11.4 called it processing.
- Presentation delay: from the end of the handlers until the next frame is on the screen.
Add them up, and you get the whole tap: 2.4 + 300.2 + 1.4 = 304 ms.
You phone a pizza place. The pizza comes 45 minutes later. Whose fault was it? Maybe the phone rang and rang, because the cook was busy. Maybe your pizza took ages to make. Or maybe it sat by the oven until the driver came. A faster driver won't help if the phone rang for half an hour.
- In real life: You phone the pizza place
- In the browser: The tap: the entry's
startTime - In real life: Someone picks up and starts your order
- In the browser: The first handler starts:
processingStart - In real life: Your pizza is ready
- In the browser: The handlers end:
processingEnd - In real life: The pizza at your door
- In the browser: The next frame on the screen
- In real life: Finding out which wait was long, before you complain
- In the browser: Splitting the tap before you fix it
Every click, tap or key press is three waits in a row: input delay, processing duration and presentation delay. Split a slow one, find its biggest part, and fix that part first. Each part has its own usual cause, so each points to a different fix.
Is it always the handler?
Asha's tap was slow in its handler. Does a slow tap always mean a slow handler? To find out, the course made copies of the tea list. In each copy, the Sort handler is quick. Then one other thing is made slow, in a different place each time.
Here is the quick handler every copy starts from:
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 works out the order once, when the page starts.
byPriceholds the rows, cheapest first. - Line 2 adds a click handler to the Sort by price button. The handler is named
sortByPrice. - Line 3 is all the handler does.
teas.append()moves each row to the end of the list, in that order. So the list ends up sorted.
Then the course made three copies, each slow in one place:
- A busy page: a second script,
analytics.js, keeps notes on what visitors do. It packs up its notes every 5 seconds. Packing takes 300 ms. Asha taps 60 ms after it starts. - An extra listener: this time,
analytics.jsnotes every click on the page. Noting each click takes 300 ms. - A long list: the list holds 3,000 rows instead of 8. The same eight teas sit at the top. Below them are 2,992 house blends, all dearer.
The 300 ms of work is a stand-in. It's a loop that keeps the main thread busy, the way slow code would. The main thread is the one thread that runs a page's JavaScript and draws its frames.
How the course recorded it
- The browser: Chrome 157, with the CPU slowed 4 times, as a stand-in for a slower phone. DevTools offers the same 4x slowdown. Asha's window was 412 by 480 pixels, with a touch screen, as in lesson 26.1.
- The tap: Playwright, a tool that drives a real browser, tapped Sort by price once on each copy. It used Chrome's own touch input, through Chrome's DevTools protocol. DevTools waits until the page has drawn a frame before it passes a tap on. A real finger's tap isn't held. So on the busy page, the wait is worked out from the recorded work. That takes 5.3 ms off.
- What the page measured: every event entry of 16 ms or more, and every long animation frame (lesson 11.5). It also ran the web-vitals library's attribution build, version 6.2.3: the version that also points at the cause. Chrome wrote a trace too: a detailed record of everything it did, with screenshots.
Here is each copy's tap, split the same way:
// Each copy's tap, from Chrome's entries: [input delay, processing, presentation delay] in ms.const taps = { "The quick version": [9.3, 0.3, 6.4], "A busy page": [251.2, 0.8, 6.7], "An extra listener": [11.4, 301.6, 7], "A long list": [14.1, 60.4, 293.5],};const names = ["input delay", "processing", "presentation delay"];for (const [copy, parts] of Object.entries(taps)) { const total = parts.reduce((sum, part) => sum + part, 0); const biggest = names[parts.indexOf(Math.max(...parts))]; console.log(`${copy}: ${Math.round(total)} ms, mostly ${biggest}`);}- Lines 2 to 7 hold the three parts of each tap, from Chrome's click entries. The busy page's input delay leaves out DevTools' hold.
- Line 8 names the parts, in order.
- Line 10 adds up the parts. Line 11 finds the biggest one.
- Line 12 prints one line per copy. The first two are
The quick version: 16 ms, mostly input delayandA busy page: 259 ms, mostly input delay. The last two areAn extra listener: 320 ms, mostly processingandA long list: 368 ms, mostly presentation delay.
Three slow taps, each slow in a different part. And what did Asha see? The same thing every time:
The course compared the frames pixel by pixel. On every copy, her screen at the tap was the same picture. The next frame was the same sorted list. Why it matters: a visitor can't tell you which part was slow. Only the timings can.
Pick a copy below to see everything the recording kept for its tap. There's one more copy in the list, a long list measured in its handler. It comes up later.
Chrome's entry: tap at 2,860.4 ms, handlers 2,869.7 ms to 2,870 ms, duration 16 ms.
web-vitals: input delay 2.8 ms, processing 3.3 ms, presentation delay 1.9 ms. It found no long animation frame, so it names no script.
Long animation frames around the tap
None: nothing kept the main thread busy for 50 ms or more.
The Sort handler only moves the rows. Nothing else is busy.
Input delay: waiting for a busy main thread
Input delay is the wait before any of the tap's handlers can start. Some of it is always there. web.dev says it always takes a little time for the system to notice a tap and pass it to the browser. Even the quick version, with nothing else busy, waited 9.3 ms.
A long input delay has another cause. The main thread is busy with something else when the tap arrives. And the main thread does one task at a time. A task is one piece of work it picks up, like a timer's callback or a script. web.dev puts it plainly: each task runs until it finishes, however long it blocks the main thread.
You go to the post office to buy one stamp. It takes the clerk five seconds. But the customer ahead of you is posting forty parcels. The clerk serves one person at a time. So you wait four minutes for a five-second job. Nothing about your stamp was slow.
- In real life: The clerk
- In the browser: The main thread
- In real life: The customer with forty parcels
- In the browser: analytics.js packing its notes: a long task
- In real life: Buying your stamp
- In the browser: The Sort handler: 0.8 ms of work
- In real life: Waiting in the queue
- In the browser: The input delay
That's the busy page. Here is its analytics.js:
// analytics.js: every 5 seconds, pack up the notes and send them.(() => { const busy = (ms) => { const end = performance.now() + ms; while (performance.now() < end) {} }; function flush() { busy(300); navigator.sendBeacon("/notes", "notes"); setTimeout(flush, 5000); } addEventListener("load", () => setTimeout(flush, 5000));})();- Line 1 says what the script does. Lines 2 and 10 wrap it, so its names stay private.
- Line 3 is the stand-in for slow work: a loop that runs until the time is up.
- Lines 4 to 8 are
flush(). Line 5 packs the notes, for 300 ms. Line 6 sends them. Line 7 sets a timer to do it all again in 5 seconds. - Line 9 sets the first timer once the page has loaded.
Asha tapped 60 ms after flush() started. Her tap had to wait for the rest of it: 246.4 ms. Then the browser spent 4.8 ms on the tap's first events, like pointerdown. So the click's handlers started 251.2 ms after her tap. Then her handler ran for just 0.8 ms.
Chrome's own entry says 256.5 ms. The extra 5.3 ms is DevTools' hold. When flush() ended, DevTools waited for a frame before it passed the tap on. A real finger doesn't wait for that frame.
Why it matters: a faster Sort handler would change nothing here. It already took under a millisecond. The fix is in the other script.
Seeing who held the main thread
The entry says how long the wait was. It doesn't say who caused it. For that, use long animation frames, from lesson 11.5. A long animation frame is a stretch of 50 ms or more while the screen waited for its next picture. Chrome lists the scripts that ran in it.
The web-vitals library matches them to the tap for you. Its attribution build adds an attribution object to each INP report. Lesson 12.1 introduced it. Here is part of the report for the busy page:
// web-vitals' attribution for the tap on the busy page. Times in ms.const attribution = { inputDelay: 246.4, processingDuration: 5.6, presentationDelay: 6.7, longestScript: { subpart: "input-delay", entry: { invoker: "TimerHandler:setTimeout", sourceURL: "analytics.js", sourceFunctionName: "flush" }, },};const { subpart, entry } = attribution.longestScript;console.log(`The longest script: ${entry.sourceFunctionName}() in ${entry.sourceURL}`);console.log(`Started by: ${entry.invoker}`);console.log(`It ran during the ${subpart}.`);- Line 3 holds web-vitals' three parts, without DevTools' hold. They add up to 258.7 ms, the whole tap.
- Lines 4 to 7 hold
longestScript: the longest script in the long animation frames around the tap. Line 5 says which part it fell in. Line 6 says what started it, its file, and its function. - Line 9 takes
subpartandentryout of it. Lines 10 to 12 printThe longest script: flush() in analytics.js,Started by: TimerHandler:setTimeoutandIt ran during the input-delay.
TimerHandler:setTimeout means a setTimeout timer started this script. web-vitals picks the part from when the script started. This one started before the handlers did, so it lands in input-delay. The recording agrees. flush() started 59.9 ms before the tap, and ran for 306.3 ms.
Look at line 3 again. web-vitals says 246.4 ms of input delay. The click's entry said 251.2. Both are right. web-vitals reads every event of the tap that reached the same frame, as web.dev recommends. It counts from the tap until the browser started on the first of them. Here, those were pointer events like pointerdown, just before the click. Why it matters: small gaps between tools are normal. Look at which part is biggest, not at the decimals.
In DevTools, the Interactions track draws input delay as a thin line on the left of each interaction. The docs call it a whisker. Lesson 7.2 showed it.
What usually makes it long
web.dev names these causes:
- Timers that do a lot of work, like this analytics flush. web.dev warns that
setIntervalis more likely to get in the way thansetTimeout, because it runs again and again. - Scripts running as the page starts. A tap during start-up waits behind them. Lesson 23.3 saw hydration do this: a framework making the server's HTML work.
- Interactions that come close together. When you type fast, each key can wait for the last key's work.
- Animations run in JavaScript that do a lot of work in every frame.
The fixes come in Module 27, The main thread. Break long tasks into short ones, and yield: pause, so the browser can handle a tap in between. Those lessons are in Responsiveness: INP.
Processing duration: every handler for the tap
Processing duration starts when the first handler starts. It ends when the last one ends. web.dev calls it the time the event callbacks take to run to completion.
"The handlers" can be more than you think. One tap fires several events, like pointerdown, pointerup and click. Each event can have many listeners. A listener is a function added with addEventListener. It's another word for a handler. It can sit on the button, or on the page around it. And any script can add one, yours or someone else's.
You hand in a form at the town hall. It goes from desk to desk, and each desk stamps it. Your desk is quick. But one desk also copies every form into a big book, by hand. You get your form back only after the last stamp. And you never chose that slow desk.
- In real life: The form going round the desks
- In the browser: The tap's events being handled
- In real life: Each desk's stamp
- In the browser: Each listener running
- In real life: Your own quick desk
- In the browser: Ravi's
sortByPrice - In real life: The desk that copies every form by hand
- In the browser: analytics.js's
noteClick: 300 ms - In real life: Getting your form back
- In the browser: The handlers end:
processingEnd
That's the extra-listener copy. Its analytics.js adds one listener:
// analytics.js: note every click on the page.(() => { const busy = (ms) => { const end = performance.now() + ms; while (performance.now() < end) {} }; document.addEventListener("click", function noteClick() { busy(300); });})();- Line 4 adds a click listener to the
document, the page itself. Clicks on a button travel up through the page to the document. So this listener hears every click. - Line 5 is the stand-in for its work: 300 ms.
So when Asha tapped Sort, two listeners ran. Ravi's ran first, on the button. Then the click reached the document, and noteClick ran. Chrome's entry put 301.6 ms between processingStart and processingEnd. Why it matters: processing isn't only your handler. Every listener for the tap's events adds to it.
Seeing whose code ran
Long animation frames name each script that ran for more than 5 ms. Here is the longest script from two taps. Both spent about 300 ms in processing:
// The longest script in each tap's long animation frame. Durations in ms.const scripts = [ { copy: "The original", invoker: "BUTTON#sort.onclick", sourceURL: "shop.js", duration: 303.4 }, { copy: "An extra listener", invoker: "#document.onclick", sourceURL: "analytics.js", duration: 300.1 },];for (const script of scripts) { console.log(`${script.copy}: ${script.invoker} in ${script.sourceURL}, ${script.duration} ms`);}- Line 3 is the original page from lesson 26.1, tapped on the slowed CPU. Its longest script was
BUTTON#sort.onclick. That's the click listener on the button whose id issort, from shop.js. - Line 4 is the extra-listener copy. Its longest script was
#document.onclick: a click listener on the document, from analytics.js. - Lines 6 to 8 print
The original: BUTTON#sort.onclick in shop.js, 303.4 msandAn extra listener: #document.onclick in analytics.js, 300.1 ms.
Same part, two owners. On the extra-listener copy, Ravi's sortByPrice isn't listed at all. It ran for less than 5 ms. web-vitals agreed: its longest script was #document.onclick, during the processing-duration. Why it matters: before you rewrite a handler, check whose code was slow.
In DevTools, the Interactions track draws processing as the solid bar between the two whiskers.
What usually makes it long
- A handler that does too much before the screen can change: sorting big lists, saving, sending. web.dev advises doing only what the next frame needs, and the rest later.
- Other scripts' listeners, like an analytics script's.
- Building lots of HTML in JavaScript. web.dev warns that creating too much HTML at once on the client can hurt INP.
- Reading a size right after changing the page. The next section shows why.
The fixes come in Module 28, Fast interactions: show the result first, and move the rest out of the way.
Presentation delay: drawing the new screen
Presentation delay starts when the last handler ends. It ends when the next frame is on the screen. web.dev and the web-vitals source list what can happen in between:
requestAnimationFramecallbacks, which run just before the browser draws;ResizeObserverandIntersectionObservercallbacks;- style: working out which CSS rules apply to each element;
- layout: working out each element's size and place;
- paint, and the work after it to put the picture on the screen.
Lesson 6.3, The pixel pipeline, walked through style, layout and paint.
Meera tells her staff to put the teas in price order. Saying it takes two seconds. Then they move every tin. With eight tins, it's done before you notice. With three thousand, the shop stays shut all afternoon. The instruction was short. The moving was the slow part.
- In real life: Meera's instruction
- In the browser: The handler:
teas.append(...byPrice) - In real life: Moving every tin
- In the browser: Style and layout for every row
- In real life: Opening the doors again
- In the browser: The next frame on the screen
- In real life: Eight tins, or three thousand
- In the browser: 8 rows, or 3,000
That's the long list. Its handler is the quick one from before. But it moves 3,000 rows, not 8. Chrome's entry gave the handler 60.4 ms. Then came 293.5 ms of presentation delay.
What the browser did after the handler
Here the long animation frame helps less. It named one script, BUTTON#sort.onclick, for 60.3 ms. Its rendering, from renderStart to the frame's end, took 1 ms. web-vitals left 288.1 ms of the tap unattributed: time it couldn't put on a script, on style and layout, or on paint.
So where did the time go? The trace answers that, because it records every step Chrome took. The course added up the main thread's work after the handler:
// The long list: the main thread's work after the handler, from Chrome's trace. Times in ms.const work = { "Recalculate style": 52.8, Layout: 176.4, "Pre-paint": 43.9, Paint: 10.2 };const presentationDelay = 293.5; const total = Object.values(work).reduce((sum, ms) => sum + ms, 0);console.log(`Rendering work: ${total.toFixed(1)} ms of ${presentationDelay} ms`);console.log(`Layout alone: ${work.Layout} ms`);- Line 2 holds four steps from the trace, with the names DevTools gives them. Recalculate style is the style step. Pre-paint gets ready for paint, as lesson 6.3 showed.
- Line 3 is the presentation delay from Chrome's entry.
- Line 5 adds up the steps. Lines 6 and 7 print
Rendering work: 283.3 ms of 293.5 msandLayout alone: 176.4 ms.
The trace shows one more thing. Chrome did the style, layout and pre-paint in the same task as the click, right after the handler. That was before renderStart, where the long animation frame starts counting rendering. Why it matters: when web-vitals leaves a big part unattributed, record a trace. Lesson 26.3 shows how to reproduce a slow tap in DevTools.
Lesson 11.4's rounding applies here, too. Chrome rounds each duration to the nearest 8 ms. Presentation delay is what's left of it, so it can be off by up to 4 ms.
When the handler asks for a size
The last copy is the long list again. This time, the handler also checks the list's height. It uses it to decide whether to show a Back to top link:
const byPrice = [...teas.children].sort((a, b) => a.dataset.price - b.dataset.price);document.querySelector("#sort").addEventListener("click", function sortByPrice() { teas.append(...byPrice); document.querySelector("#top").hidden = teas.offsetHeight < innerHeight;});- Lines 1 to 3 are the quick handler again.
- Line 4 reads
teas.offsetHeight, the list's height in pixels. If the list is shorter than the window, it hides the link. - Line 5 closes the handler.
Line 4 has a cost. The rows have just moved, so Chrome doesn't know the list's height yet. To answer, it must work out style and layout right there, inside the handler. This is called a forced layout.
So the layout moved. The long animation frame says the script ran for 215.1 ms. Of that, 170.5 ms was forced style and layout. Chrome's entry gave processing 215.1 ms, and presentation delay only 47.1 ms.
Why it matters: the same layout can land in processing or in presentation delay. It depends on when it runs. Reading a size doesn't save the layout. It only moves it earlier. web.dev's INP guide lists this, layout thrashing, under making event callbacks faster.
What usually makes it long
- A big page. web.dev says rendering work grows with the size of the DOM, the tree of elements on the page. Its guide quotes Lighthouse: over 1,400 nodes is too many.
- Big changes, like adding or moving many elements at once.
- Work in animation frame or resize callbacks, which run before the next frame.
web.dev says presentation delays can be the hardest to troubleshoot. Module 28 comes back to them, with a whole lesson on this part.
Now try all three causes in your own browser. Pick what to make slow, then press Sort. Watch which bar grows:
// What the panel runs in each mode, shortened.if (mode === "busy page") setInterval(() => busy(150), 250);button.addEventListener("click", () => { if (mode === "heavy handler") busy(150); const list = mode === "long list" ? longList : teaList; list.append(...[...list.children].reverse());});No taps timed yet.
Pick what to make slow, then press Sort. Your browser times the tap and splits it into its three parts.
Which phase to fix first
Back to Ravi's question: what should he fix? web.dev's answer is short. How you speed up an interaction depends on which part takes the most time. So split the slowest interaction, and start with its biggest part.
Asha's trip to work takes an hour. She walks five minutes to the bus. The bus takes fifty. Then she walks five more. Walking twice as fast saves her five minutes. A quicker bus route could save thirty. So she checks the timetable before she buys new shoes.
- In real life: The three legs of the trip
- In the browser: Input delay, processing, presentation delay
- In real life: The bus ride
- In the browser: The biggest part
- In real life: Walking faster
- In the browser: Fixing a small part: little changes
- In real life: Checking the timetable first
- In the browser: Splitting the tap before you fix it
Here is the long list's tap, with each part taken away in turn:
// The long list's tap, in ms. What if one phase took no time at all?const tap = { "input delay": 14.1, processing: 60.4, "presentation delay": 293.5 };const total = Object.values(tap).reduce((sum, ms) => sum + ms, 0); for (const [phase, ms] of Object.entries(tap)) { console.log(`Without ${phase}: ${(total - ms).toFixed(1)} ms`);}- Line 2 holds the three parts. Line 3 adds them up: 368 ms.
- Lines 5 to 7 take each part away in turn. They print
Without input delay: 353.9 ms,Without processing: 307.6 msandWithout presentation delay: 74.5 ms.
Even an instant handler leaves the tap at 307.6 ms, still over 200. Only a fix to the drawing brings it under. Why it matters: a fix to the wrong part can be good work, and still not move INP.
Try it on the other copies:
- Without input delay
353.9 msneeds improvement - Without processing
307.6 msneeds improvement - Without presentation delay
74.5 msgood
It's the biggest part. Without it, this tap would take 74.5 ms: good.
Each part points to a different fix
Read this table across. Each row is one part: where to look, what usually makes it long, and where the course fixes it.
| Which part is long? | What to look at | Usual causes | Where the fix is taught |
|---|---|---|---|
| Input delay | The long animation frame that was running at the tap. Its scripts started before the tap. | Timers, scripts loading during start-up, other taps that came just before. | Module 27, The main thread: break up long tasks and yield. |
| Processing | The scripts in the frame that ran the handlers, and every listener for the tap's events. | Heavy handlers, other scripts' listeners, reading sizes right after changing the page. | Module 28, Fast interactions: show the result first, do the rest later. |
| Presentation delay | The trace's Main track after the handlers: style, layout, paint. | A big page to lay out again, large changes, animation frame and resize callbacks. | Module 28, Fast interactions: change and draw less after each tap. |
What real sites see
No part is always the biggest. The 2024 Web Almanac looked at the three parts on real sites. Its data came from RUMvision, a service that measures real visits. For the median interaction, the one in the middle, presentation delay was the biggest part, at 36 ms. For the slowest ones, input delay was. At the 90th percentile, it reached 155 ms. That's the value 90 in 100 interactions were at or under.
Why it matters: you can't guess which part is slow on your own site. Measure it.
Two more tips. First, start with your slowest interactions, because INP is the slowest one. Second, note when the tap happened. Input delays can be long while the page is still loading, when the main thread is often busiest. web-vitals reports when it happened as loadState. Lesson 26.3 shows how to find these taps in data from real visits.
Which phase does it slow down?
Each thing below makes a tap slower. Sort it: which part of the tap does it make longer?
- A chat widget's timer runs for 200 ms just as the visitor taps
- The visitor taps while a big script is still being run as the page loads
- The visitor presses a second key while the first key's handler is still running
- Another script's click listener does 200 ms of work
- The handler filters 10,000 products in JavaScript
- The handler reads offsetHeight right after changing the list
- The handler is quick, but 3,000 rows must be laid out again
- A requestAnimationFrame callback does 150 ms of work after the tap
- A ResizeObserver callback does heavy work after the tap changes a size
Sort each cause by the part of the tap it makes longer.
Common mix-ups
- "A slow interaction means a slow handler." No. On the busy page, the handler ran for 0.8 ms. The tap took 258.7 ms.
- "Processing is only my own handler." No. It's every listener for the tap's events. On the extra-listener copy, analytics.js's listener took 300.1 ms.
- "Presentation delay is the graphics card's job, so my code can't change it." No. The long list's rows made Chrome spend 176.4 ms on layout alone. Fewer rows, less layout.
- "Reading a size in the handler pushes the slowness into presentation delay." No. It pulls the layout into the handler, so it counts as processing.
- "Long animation frames always say where the time went." No. On the long list, web-vitals left 288.1 ms unattributed. Only the trace showed the layout.
- "Making the slowest handler faster always fixes INP." No. On the long list, an instant handler still leaves 307.6 ms.
- "The three parts are web-vitals' own idea." No. They come from the browser's own times. The Event Timing standard's example works them out the same way.
Practice exercises
Answer first, then use the hints.
Here is Chrome's entry for the tap on the long list, measured. What does line 6 print?
// Chrome's entry for the tap on the long list, measured. Times in ms.
const tap = { startTime: 3270.6, processingStart: 3280.4, processingEnd: 3495.5, duration: 272 };
console.log(`Input delay: ${(tap.processingStart - tap.startTime).toFixed(1)} ms`);
console.log(`Processing: ${(tap.processingEnd - tap.processingStart).toFixed(1)} ms`);
console.log(`Presentation delay: ${(tap.startTime + tap.duration - tap.processingEnd).toFixed(1)} ms`);Presentation delay: 47.1 ms. The next frame came at 3542.6 ms, and the handler ended at 3495.5 ms. The layout had already run inside the handler.
This asks the same question of the busy page's tap. What does the first line print?
// The busy page's tap, in ms. What if one phase took no time at all?
const tap = { "input delay": 251.2, processing: 0.8, "presentation delay": 6.7 };
const total = Object.values(tap).reduce((sum, ms) => sum + ms, 0);
for (const [phase, ms] of Object.entries(tap)) {
console.log(`Without ${phase}: ${(total - ms).toFixed(1)} ms`);
}Without input delay: 7.5 ms. Almost the whole tap on the busy page was waiting. Take the wait away, and the tap is quick.
In the first playground, pick An extra listener. What blocking duration does its long animation frame show?
255.9 ms. The frame lasted 306 ms. Blocking duration leaves out the first 50 ms of a long task, as lesson 11.5 showed.
In the take-one-part-away playground, pick An extra listener. What total does it show without processing?
18.4 ms, which is good. The extra listener's tap was 320 ms, and 301.6 ms of it was processing.
Open the course's trace of the long list's tap in Chrome's DevTools. After the click handler, Chrome recalculated style. How many elements does DevTools say were affected?
12000. Chrome worked out styles again for 12,000 elements: 3,000 rows, each with four elements (the row, two spans and a button). That's why the style step took so long.
web-vitals groups a tap's events that reached the same frame. How close, in ms, must their render times be?
8 ms. web-vitals adds startTime and duration to get each entry's render time. If two are within 8 ms of each other, it treats them as one frame. That's how it reads every event of a tap together.
Check your understanding
For each question, ask when the work ran: before the handlers, during them, or after them.
Question 1 of 7This code reads one copy's entry and picks its biggest part. What does it print?
Read the code, then predict// Chrome's entry for one copy's tap. Times in ms. const tap = { startTime: 2854.1, processingStart: 2865.5, processingEnd: 3167.1, duration: 320 }; const parts = { "input delay": tap.processingStart - tap.startTime, processing: tap.processingEnd - tap.processingStart, "presentation delay": tap.startTime + tap.duration - tap.processingEnd, }; const biggest = Object.keys(parts).reduce((a, b) => (parts[b] > parts[a] ? b : a)); console.log(`Fix the ${biggest} first.`);Choose an answer to see the explanation.
Question 2 of 7Asha taps Sort by price while a timer's callback from another script is still running. Which part of her tap grows?
Choose an answer to see the explanation.
Question 3 of 7Ravi's click handler is quick, but another script listens for every click on the document and does slow work. Where does that work show up?
Choose an answer to see the explanation.
Question 4 of 7A handler only moves the rows of a list, but the list has thousands of rows. Where does most of the tap's time go?
Choose an answer to see the explanation.
Question 5 of 7A handler moves the rows, then reads the list's offsetHeight. Where does the layout time go now?
Choose an answer to see the explanation.
Question 6 of 7This code asks what the long list's tap would take without each part. Which line would INP rate as good?
Read the code, then predict// The long list's tap, in ms. What if one phase took no time at all? const tap = { "input delay": 14.1, processing: 60.4, "presentation delay": 293.5 }; const total = Object.values(tap).reduce((sum, ms) => sum + ms, 0); for (const [phase, ms] of Object.entries(tap)) { console.log(`Without ${phase}: ${(total - ms).toFixed(1)} ms`); }Choose an answer to see the explanation.
Question 7 of 7web-vitals' attribution left most of a slow tap's time unattributed. What's the best next step?
Choose an answer to see the explanation.
Key takeaways
- Every interaction is input delay, then processing, then presentation delay. Together they make its duration.
- Input delay is waiting for a busy main thread. On the busy page, a 0.8 ms handler waited 251.2 ms.
- Processing is every listener for the tap's events, yours or not. Long animation frames name the script.
- Presentation delay is the drawing after the handlers. Big pages and big changes make it long. A trace shows the steps.
- Split the slowest interaction, and fix its biggest part first.
Remember the one-liner.
Split the slow tap into its three waits, and fix the biggest one first.
Sources
The facts on this page come from these specs, docs and source files:
- Event Timing, W3C.
- Optimize Interaction to Next Paint, web.dev.
- content/browser/devtools/protocol/input_handler.cc, Chromium 157.0.8103.52, Chromium on GitHub.
- Optimize input delay, web.dev.
- Optimize long tasks, web.dev.
- attribution/onINP.ts, web-vitals 6.2.3, GoogleChrome on GitHub.
- types/inp.ts, web-vitals 6.2.3, GoogleChrome on GitHub.
- Long Animation Frames API, Chrome for Developers.
- Manually diagnose slow interactions in the lab, web.dev.
- Performance features reference, Chrome for Developers.
- Styles.ts, Chrome DevTools, ChromeDevTools on GitHub.
- Client-side rendering of HTML and interactivity, web.dev.
- DOM size and interactivity, web.dev.
- Performance, Web Almanac 2024, HTTP Archive.
Coming next: Finding slow interactions. It finds the slow taps in data from real visits, then reproduces them in DevTools.