The metric your stream fails
Of the three Core Web Vitals, a streaming agent surface fails exactly one — INP, whose 200ms budget is spent on input delay and presentation delay that a token loop owns entirely, while LCP and CLS stay green and tell you nothing.
Your flight chatbot answers in one shot today: send a prompt, wait, render the whole recommendation. Nothing about it is fast, but nothing about it is unresponsive either, because between the request going out and the answer coming back the main thread has nothing to do. The browser sits idle. A click on anything lands instantly.
Now stream it. Deltas arrive over the wire, each one appends to the answer, and if each one also calls setState then React renders the transcript again, and again, for as long as the model keeps talking. Somewhere in the middle of that the user changes their mind and clicks stop. That click has to be noticed by a main thread you have spent the last eleven seconds saturating — and the stop button is not one control among many, it is the one control that exists because the user is already unhappy.
Here is the part that makes this a measurement problem rather than an intuition problem: run Lighthouse against that page and it will very likely score in the nineties. Two of the three Core Web Vitals are structurally incapable of noticing what just happened. The third is the entire subject of this course.
What INP actually measures
Interaction to Next Paint “assesses a page’s overall responsiveness to user interactions by observing the latency of all click, tap, and keyboard interactions that occur throughout the lifespan of a user’s visit to a page,” per web.dev. Three words in that sentence are doing work.
Latency, not duration of the response. INP does not care how long your model takes to answer. It measures the gap between the user acting and the browser painting a frame that acknowledges the action — a button going into its pressed state counts. An eleven-second stream with a stop button that visibly depresses in 40ms is a good INP. A two-second stream where the button takes 600ms to look pressed is a bad one.
All interactions, throughout the visit. Not the first one. This is the difference from the metric INP replaced, and it is the whole reason this course exists: First Input Delay only ever looked at the first interaction on the page, which on a chat surface happens before a single token has arrived, on a page that is doing nothing.
Click, tap, and keyboard. The same page notes that scrolling, hovering and zooming are not counted. Typing into the prompt box is measured. Scrolling the transcript is not — which is worth knowing before you spend a day optimising a scroll handler and wonder why the number did not move.
The three phases, and which ones a stream owns
Every interaction’s latency decomposes into three parts, named on the optimize INP page (last updated 2 September 2025):
- Input delay — from the user initiating the interaction until the event callbacks for it begin to run.
- Processing duration — the time it takes for those event callbacks to run to completion.
- Presentation delay — the time from the callbacks finishing until the browser presents the next frame containing the visual result.
Read that list against your stream. Your onClick handler for the stop button is three lines — abort the fetch, set a flag. The processing duration is nothing. The damage is entirely in the two phases you do not write code for. Input delay is the queue behind a main thread already running a render. Presentation delay is the frame you cannot paint until the render triggered by the flag change, and the four chunk renders that arrived while you were doing it, have all committed.
That asymmetry is the most useful thing in this lesson. Engineers reflexively optimise the handler, because the handler is the code they wrote. Under a stream, the handler is almost never the problem.
Where people get burned
A task that occupies the main thread for more than 50 milliseconds is a long task. Not a slow one — a long one, in the specification’s sense. A single React render of a transcript with forty messages, markdown-parsed, blows past 50ms without effort on a mid-tier phone. Do that forty times a second and input delay is not an edge case; it is the steady state.
The thresholds, and the percentile nobody reads carefully
From the same page: an INP at or below 200 milliseconds is good; above 200 and at or below 500 milliseconds needs improvement; above 500 milliseconds is poor. Those numbers are assessed at the 75th percentile of page loads, segmented across mobile and desktop, which is the standard the Core Web Vitals overview sets for all three metrics.
The 75th percentile is the part that gets skimmed. It does not mean “most users are fine.” It means you are being graded on the experience of your unluckiest quarter — older phones, thermally throttled laptops, a browser with thirty tabs open, the portfolio company whose page already ships 900KB of its own JavaScript before your widget mounts. Your machine is not in that quartile and never will be.
Then there is how a single INP value is chosen from a whole visit. For most pages it is simply the worst interaction. On pages with many interactions, web.dev states that the highest interaction is ignored for every 50 interactions, to keep one freak outlier from defining the score. A chat surface generates interactions steadily — every keystroke in the prompt box is one — so a long session gets some outlier tolerance. Do not plan around it. It discards the worst; it does not discard the second worst, and if your stop button is reliably slow it will be the second worst too.
Why LCP and CLS will tell you the page is fine
This is not a case of the other two metrics being less sensitive. They are, by their own definitions, measuring a window that closes before the interesting part starts.
LCP reports the render time of the largest image or text block visible in the viewport, and the LCP article (last updated 4 September 2025) is explicit about when it stops: “ The browser will stop reporting new entries as soon as the user interacts with the page (via a tap, scroll, or keypress).” On a chat surface, the user’s first act is to type into the prompt box. LCP has stopped recording before your first token exists. Its threshold, 2.5 seconds, is a statement about an empty chat window.
CLS looks better on paper — it measures unexpected layout shifts across the entire page lifecycle, so a growing transcript is at least inside its window. But the CLS article excludes shifts that occur within 500 milliseconds of user input, flagging them hadRecentInput. The whole point of a chat is that content appears because the user just asked for it. And structurally a transcript appends at the bottom, pushing nothing above it. A streaming answer is close to the definition of an expected shift.
So the scorecard on a badly built streaming surface reads: LCP good, CLS good, INP poor. Two greens and a red, and the two greens are measuring a page that no longer exists by the time the problem happens.
Where the number comes from in the browser
INP is not a black box. It is computed from PerformanceEventTiming entries, and MDN documents the arithmetic well enough that you can reproduce the phase split yourself:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const inputDelay = entry.processingStart - entry.startTime
const processing = entry.processingEnd - entry.processingStart
const total = entry.duration
console.warn(entry.name, { inputDelay, processing, total })
}
})
observer.observe({ type: 'event', durationThreshold: 16, buffered: true })Two details from MDN that change how you read the output. duration runs from startTime to the next rendering paint and is rounded to the nearest 8ms, so do not chase single-millisecond differences. And durationThreshold defaults to 104ms with a minimum of 16ms — leave it at the default and short interactions are invisible to you, which is fine for finding failures and misleading if you are trying to establish a baseline. The interface reached Baseline in December 2025, so it is newly available rather than long-settled.
In practice you should not hand-roll this. Google’s web-vitals library (v5) implements the full selection rule — including the outlier handling — in a few lines of your code:
import { onINP } from 'web-vitals/attribution'
onINP((metric) => {
const { inputDelay, processingDuration, presentationDelay, interactionTarget } =
metric.attribution
send({ value: metric.value, inputDelay, processingDuration, presentationDelay, interactionTarget })
})The attribution build costs about 1.5KB brotli’d over the standard build, by the library’s own README, and buys you the phase breakdown plus a selector naming the element the user actually hit. Without it you get a number and no idea which of the three phases to go fix — which, given that this lesson’s whole argument is that the phase matters more than the total, is not a saving worth making.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
Name the one structural change to a streaming UI that moves INP the most, and say which phase it attacks.
Check your answer
Decouple the arrival rate of chunks from the render rate of React. Every chunk that becomes a state update is a render scheduled on the main thread, and the length of that queue is your input delay and a large part of your presentation delay. Accumulating into a ref and flushing on a chosen cadence — or into an external store React subscribes to — attacks both phases at once, because it reduces the number of renders rather than the cost of each one.
Notice what it does not attack: processing duration. Nothing about the stop button’s own handler changes. That is the point of splitting the metric into phases before touching any code — it tells you that the fix lives in the streaming machinery, not in the control that appears to be broken.
Hands on
Record the baseline you are going to be judged against
Done when: MEASUREMENTS.md has a filled “before” column for the stop-button interaction: an INP value in milliseconds, split into input delay, processing duration and presentation delay, taken under CPU throttling, with the device and throttling level written next to it.
- Open
learning/frontend-performance/MEASUREMENTS.md. It ships with the fields named and every number blank. Do not add fields yet; fill the ones that are there. - Add
web-vitalsto the flight chatbot and wireonINPfrom theweb-vitals/attributionbuild, logging withconsole.warn(this repo’s ESLint config forbidsconsole.log). If your streaming version is not built yet, do this against the request/response version first — a baseline on the non-streaming build is a legitimate row and makes the later comparison sharper. - In DevTools, set CPU throttling before you touch the page. Record the exact setting you used in the file. An unthrottled number from a modern laptop is not a baseline, it is a hardware spec.
- Send a prompt that produces a long answer. While it is streaming, click stop. Read the INP that
onINPreports and write down all four numbers: total, input delay, processing duration, presentation delay. - Add one sentence under the row naming which of the three phases holds most of the time. Commit to a phase in writing before you have a fix in mind — that sentence is the thing the after-column will either confirm or embarrass.
- Bring the four numbers into the chat. I will push back if the sum of the phases does not roughly reconcile with the total, or if the throttling line is missing.
What this does not cover
You now have a number from your own browser, which is a lab number, and a lab number is the specific kind of evidence that is easiest to be confidently wrong with. Lighthouse will hand you a score that contradicts it; the lab-versus-field lesson explains why the two instruments disagree by construction, what Lighthouse is actually scoring instead of INP, and what it takes to get a number back from real users instead of from your own machine.
Nothing here tells you which render is eating the interaction either. That is the profiling lesson: recording a trace while the stream is live and reading the interactions track and flame chart in the right order. And the fixes — keeping the stream out of React state, the memo boundaries that are theatre, the external-store escape hatch — all live in the React module, deliberately after you have a measurement, because a fix applied before a baseline exists cannot be shown to have worked.
Read this next — primary source
Interaction to Next Paint (INP)Jeremy Wagner and Philip Walton, web.dev — free; last updated 2 September 2025. Google defining, measuring and ranking on its own metric.
This lesson takes the definition, the three phases and the thresholds. The full article carries the parts that decide whether your own measurement is honest: exactly which event types are grouped into one interaction, why the reported value is not simply the worst interaction on high-traffic pages, how INP behaves on pages a user never interacts with at all, and the section on why a good INP is harder to reach on pages that do heavy work in response to input. Read it before you argue with anyone about a number you collected.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.