Profiling a live stream
The interactions track names the phase, the flame chart names the function, and CPU throttling is what makes the failure reproducible on a machine far too fast to feel it — record the stop button being clicked mid-stream and read the trace in that order.
You have an INP of 480ms and a written commitment that most of it is input delay. That commitment is a hypothesis, and it is about to be tested against a flame chart. Roughly half the time, engineers who write down a phase in advance are wrong — usually because the render they assumed was expensive is cheap and something adjacent, a markdown parse or a highlighter, is not.
Profiling a stream is harder than profiling a normal interaction for one specific reason: the thing you are measuring only exists while something else is happening. You cannot record a clean trace of the stop button. You have to record a trace of a stream that has a stop button click buried in the middle of it, and then find the click. That changes both how you set up the recording and the order in which you read it.
Set up the machine before you record anything
A trace recorded on an unthrottled modern laptop is close to useless for this problem, because the failure you are hunting is a queueing failure and your machine drains the queue too fast to queue. web.dev’s lab-diagnosis guide is direct about it: mobile devices are often not as fast as laptops or desktop machines, and the recommended order is to use remote debugging against a physical Android device where you can, and to enable CPU throttling in DevTools where you cannot.
Chrome’s throttling options have moved recently enough that stale advice is common. Chrome 126 added the ability to throttle the CPU by 20 times, on top of the existing multipliers. Then, per What’s new in DevTools 134 (21 February 2025), Chrome added calibration: “You can now automatically calibrate and get two additional CPU throttling presets that more accurately approximate low- and mid-tier mobile devices.” Run it once, from the CPU throttling dropdown in the Performance panel, and you get presets scaled to your own hardware rather than an abstract multiplier.
Use the calibrated preset over a raw multiplier. A raw 4x on an M-series laptop and a raw 4x on a five-year-old ThinkPad are two different machines wearing the same label, which makes numbers uncomparable between you and a colleague — and comparability is the entire point of writing them into a file.
Where people get burned
Record the throttling setting next to every number, every time. A before-and-after where the “before” was taken at 20x and the “after” at 4x is not a measurement of your fix, it is a measurement of the dropdown. This is the single most common way a performance improvement gets reported that did not happen, and it is embarrassing in exact proportion to how loudly it was announced.
Read the trace in three passes, in this order
Pass one: the live metrics view, before you record
Open the Performance panel and do nothing. Since the change described in Chrome’s live-metrics announcement (17 September 2024), the panel shows LCP, CLS and INP for the current page in real time without a recording, colour-coded against their thresholds, alongside “a real time log of all eligible interactions that occur while DevTools is open” — each entry naming the interaction type, pointer or keyboard, with its latency.
Use this to find the interaction worth recording before you take the expensive trace. Stream, click stop, watch the interactions log, and see what latency it reports. If the log says 60ms, you are not going to find a 480ms problem in the trace — you are going to find that your reproduction is wrong, which is worth knowing in ten seconds rather than after ten minutes of flame-chart archaeology.
Pass two: the interactions track, to name the phase
Now record: start the recording, send the prompt, let the stream run, click stop mid-stream, stop the recording. In the resulting trace, the Performance panel’s Interactions track draws each interaction with whiskers showing input and presentation delays; hovering gives a tooltip with input delay, processing time and presentation delay, and clicking an interaction flagged with an INP warning shows the phase breakdown in the Summary tab.
That is the moment your hypothesis is confirmed or killed. Three outcomes, three different investigations:
- Input delay dominates. The main thread was busy when the click arrived. The culprit is upstream of your handler entirely — look at what is running immediately before the interaction starts, not at anything the click triggers.
- Processing duration dominates. Your handler is genuinely slow, or something synchronous is hanging off it. Rare in a stream, and if you see it, suspect a synchronous layout read or a subscriber doing work you did not know about.
- Presentation delay dominates. The callbacks finished and the frame still could not paint — queued renders, an enormous DOM to lay out, or style recalculation over the whole transcript.
Pass three: the flame chart, to name the function
Only now go to the Main track. Align it with the interaction you selected in the Interactions track — that alignment is the entire reason to read the tracks in this order, and it is what the lab-diagnosis guide recommends. Long tasks are highlighted in red above the flame chart, so the visual scan is: find the red block that overlaps the interaction, then descend into it.
A trace of a chunk-per-render stream has a characteristic look worth recognising: a dense picket fence of near-identical stacks, one per chunk, each topped by the same commit work. If your stop click lands inside that fence, the input delay is the fence. Nothing about the click is slow; it simply arrived in a queue you built.
The other tracks earn their place here too. Frames shows dropped and partially presented frames, which is the visual counterpart of a bad presentation delay. Timings surfaces your own performance.mark() and performance.measure() calls, which is how you make the boundary between “chunk arrived” and “React committed” visible in the trace rather than inferred from stack shapes. Mark your flush points before you record; you cannot add them afterwards.
Where React DevTools helps, and where it misleads
The React Profiler answers a different question from the Chrome Performance panel, and confusing the two costs people hours. Chrome measures the browser’s work: script, style, layout, paint, and the gap before a frame appears. React measures its own render work, and nothing else.
The mechanism is documented on react.dev’s <Profiler> reference, and the two duration values are the useful part:
<Profiler id='transcript' onRender={onRender}>
<Transcript messages={messages} />
</Profiler>
function onRender(id, phase, actualDuration, baseDuration, startTime, commitTime) {
// phase: 'mount' | 'update' | 'nested-update'
}actualDuration is the milliseconds spent rendering this tree for the current update; baseDuration estimates what it would cost to re-render the whole subtree with no optimisations. React’s own documentation says actualDuration “indicates how well the subtree makes use of memoization” — so the ratio between the two is the honest test of whether a memo boundary is doing anything. A tree where actualDuration tracks baseDuration on every update has memoization that is not working, whatever the code looks like.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
You record a trace, click stop mid-stream, and the Interactions track shows no entry for your click at all. What happened?
Check your answer
Most likely the click never became an interaction — it landed on a disabled button, on a container rather than the control, or on an element that had just been replaced by a re-render and no longer had a handler attached. That last case is worth dwelling on, because it is a correctness bug that a performance investigation surfaces: a transcript re-rendering aggressively can swap the stop button out from under a click that was already in flight.
The other possibility is that you are looking for the wrong signal. Only click, tap and keyboard interactions are counted — if what you actually did was drag or scroll, there is nothing to find, because INP does not observe those at all.
Either way, the trace has already told you something. A missing interaction is a finding, not a failed recording, and it is a better starting point than a 480ms one.
Hands on
Find the render that eats the interaction
Done when: MEASUREMENTS.md names a specific function or component — by name, from the flame chart — as the largest contributor to the dominant phase of the stop-button interaction, with the throttling setting and the phase split recorded next to it.
- Run CPU throttling calibration once (Performance panel, CPU throttling dropdown, Calibrate), then select the mid-tier mobile preset. Write the preset name into
MEASUREMENTS.md. If your Chrome is older than 134 and has no calibration, use a fixed multiplier and record the multiplier instead. - Add
performance.mark()calls at two points in the streaming code: where a chunk is received, and immediately after the state update that renders it. Without these, the trace shows you stack shapes and you have to infer the boundary; with them, the Timings track shows it directly. - Record: start the trace, send a prompt long enough to stream for several seconds, click stop somewhere in the middle, stop the trace. Do it twice — once clicking early in the stream and once clicking late — and keep both.
- In each trace, select the stop interaction in the Interactions track and read the phase breakdown from the Summary tab. Write both phase splits into the file. Then compare them: if the late click is materially worse, you have just demonstrated that the cost grows with transcript size, which is a different problem from a fixed per-chunk cost and gets a different fix.
- Align the Main track with the interaction and descend into the largest red-flagged long task overlapping it. Write down the top one or two named frames — the actual function or component names, not “React stuff.”
- Go back to the phase you committed to in writing in the INP lesson. State in one line whether the trace confirmed it or contradicted it. If it contradicted it, leave the original prediction in the file rather than editing it — a record of what you expected is worth more than a record of what turned out to be true.
- Bring the phase split and the named frames into the chat. I will push back if the named frame is generic, or if the two traces were taken at different throttling levels.
What this does not cover
You can now produce a defensible number and point at the code producing it. Everything after this is fixes, and they are deliberately unshipped until your MEASUREMENTS.md has a real before-column, because a fix without a baseline cannot be shown to have worked.
The React module takes the two conclusions this lesson most often reaches. If the trace showed a picket fence of per-chunk renders, the keeping-the-stream-out-of-state lesson is the fix and the external-store lesson is how to do it without fighting React’s rendering model; the memoization-theatre lesson exists because that is the fix most people try first, and it is the wrong one. If the trace instead showed presentation delay dominated by a huge DOM or by re-parsing, the payload module handles it — virtualizing a long conversation, and the cost of re-parsing markdown on every token.
None of that touches the streaming machinery itself: transports, event shapes, aborting a request cleanly, resuming after a refresh. That is the Streaming interfaces course, and this one assumes you either have that or are building it in parallel. This course only ever asks what the browser costs are once the bytes are already arriving.
Read this next — primary source
Manually diagnose slow interactions in the labJeremy Wagner, web.dev — free; last updated 17 October 2024. Google documenting how to drive Google’s own profiler.
This lesson takes the recording procedure, the interactions-track workflow and the throttling advice. The full article adds the parts a single lesson has to compress: how to reproduce an interaction you cannot trigger reliably, what to do when the long task is inside third-party script you do not control, and how to test interactions that happen during page load rather than after it — which is the case for an agentic panel that starts streaming as soon as it mounts. It also walks a worked example end to end, which is worth following once with the article open beside DevTools.
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.