Incident: INP regression after a release
When INP's processing grows after a release, count what one keystroke re-renders, keep fast-changing text local, and share only a rarely changing boolean.
Predict first
Before you read: predict
PAGE · 10:05 · ECON-201 candidates report that essay answers lag while they type; the RUM dashboard's INP p75 went from 142 ms to 468 ms after release 2026.09.25, and to 690 ms on school Chromebooks
The release added an "Unsaved changes" banner. Its diff:
The exam has 120 questions, and MathPreview renders each question's formula. The on-call engineer opens the exam with Chrome's CPU throttled 4× and web-vitals' attribution build reporting INP, and types one character into question 7's essay box.
Which longestScript.subpart does the attribution report for that keystroke?
import { createContext, memo, use, useState } from 'react'; const DraftContext = createContext(null); function ExamPage({ questions }) { const [drafts, setDrafts] = useState({}); const setDraft = (id, text) => setDrafts((d) => ({ ...d, [id]: text })); return ( <DraftContext value={{ drafts, setDraft }}> <UnsavedBanner /> {questions.map((q) => <QuestionCard key={q.id} q={q} />)} </DraftContext> );} const QuestionCard = memo(function QuestionCard({ q }) { const { drafts, setDraft } = use(DraftContext); return ( <section id={q.id}> <MathPreview tex={q.tex} /> {q.essay && <textarea value={drafts[q.id] ?? ''} onChange={(e) => setDraft(q.id, e.target.value)} />} </section> );}); function UnsavedBanner() { const { drafts } = use(DraftContext); return Object.keys(drafts).length > 0 ? <p role="status">Unsaved changes</p> : null;}The 30-second version
INP rose after a release. Read web-vitals' attribution: a long processing duration means the work ran inside your listeners, and React renders a typed character's update there. Count what one keystroke re-renders: a page-level context holding the draft wakes every card that reads it. Keep text local, share one rarely changing boolean, and test the render count.
When INP's processing grows after a release, count what one keystroke re-renders, keep fast-changing text local, and share only a rarely changing boolean.
How it works
Step 1 of 12
Analogy: A typing pool's carbon copies
A new office memo says every letter in progress must also appear on the 120 file copies kept for the "unsent letters" folder, so each typist now types with 120 carbons under the platen.
| In the typing pool | In the incident |
|---|---|
| the memo putting every letter on 120 copies | the release lifting drafts into a page-level context (step 3) |
| 120 carbons under the platen | 120 cards reading that context (step 7) |
| the key coming back slowly, during the strike | the renders inside the keystroke's processing (step 5) |
| the post room, blamed first | the network and the bundle, which do not run in the keystroke (steps 2, 4) |
| old manual machines, where it is worst | the school Chromebooks, approached with CPU throttling (step 8) |
| type on one sheet, clip one red "unsent" tab | text kept in the card, one boolean for the banner (steps 9–10) |
| a rule to skip copies whose text did not change | the Compiler, which cannot skip a card that reads the context (step 11) |
Breaks down when: one blow marks every carbon at once; React renders the cards one after another, so the cost grows with every card that reads the context.
Contrasting case: a slow keystroke whose attribution shows a long input delay instead. The time went to work already running when the key was pressed, such as a script still loading, and counting this keystroke's renders finds nothing.
In real terms: INP's processing duration is the time your event listeners, and the React renders they trigger, take; cut it by re-rendering fewer components per keystroke.
Examples, run for real
4.1 ExamHall EXH-3030: release 2026.09.25 against the fix
The release and the fix below, one keystroke into question 7 each: the render counts in jsdom, then web-vitals' attribution for the release in throttled Chromium. The fix keeps each draft in its card and gives the banner one boolean from a small store:
import { memo, useState, useSyncExternalStore } from 'react';
const unsavedIds = new Set();
const listeners = new Set();
const unsaved = {
mark(id) { if (!unsavedIds.has(id)) { unsavedIds.add(id); listeners.forEach((l) => l()); } },
subscribe(l) { listeners.add(l); return () => listeners.delete(l); },
};
function ExamPage({ questions }) {
return (
<>
<UnsavedBanner />
{questions.map((q) => <QuestionCard key={q.id} q={q} />)}
</>
);
}
const QuestionCard = memo(function QuestionCard({ q }) {
const [text, setText] = useState('');
return (
<section id={q.id}>
<MathPreview tex={q.tex} />
{q.essay && <textarea value={text} onChange={(e) => { setText(e.target.value); unsaved.mark(q.id); }} />}
</section>
);
});
function UnsavedBanner() {
const hasUnsaved = useSyncExternalStore(unsaved.subscribe, () => unsavedIds.size > 0);
return hasUnsaved ? <p role="status">Unsaved changes</p> : null;
}
4.1 release 2026.09.25, one character into question 7 (jsdom): QuestionCard 120 · MathPreview 120 · UnsavedBanner 1 · banner "Unsaved changes"
4.1 the fix, one character into question 7 (jsdom): QuestionCard 1 · MathPreview 1 · UnsavedBanner 1 · banner "Unsaved changes"
4.1 release 2026.09.25 in Chromium (CPU 4×), web-vitals attribution: interactionType keyboard · interactionTarget #q7>textarea · event entries keydown, keypress, beforeinput, input (repeats merged) · longestScript event-listener DIV#root.oninput, subpart processing-duration · the 120 QuestionCard renders sit in the input event's processing
The release re-rendered all 120 cards and their previews on every keystroke, inside the keystroke's processing (steps 5, 7). The fix renders one card and its preview, with the banner still shown (steps 9–10).
4.2 Streaming MR !3330: a subtitle editor's cue list
A streaming service's subtitle editor shipped the same pattern in release 6.4: each cue's text lifted into a page-level context so an "Unsaved cues" badge could show, read by every one of 300 cue rows. MR !3330 keeps each cue's text in its row and gives the badge a boolean selected from a store:
4.2 release 6.4, two characters into cue 41 of 300: CueRow renders 300, 300 · badge after each: "Unsaved cues", "Unsaved cues"
4.2 MR !3330, two characters into cue 41 of 300: CueRow renders 1, 1 · badge after each: "Unsaved cues", "Unsaved cues"
4.2 per keystroke: 300 row renders on release 6.4, 1 on MR !3330; the badge shows from the first edit on both
The MR takes a keystroke from 300 row renders to 1, and the badge still appears on the first edit (steps 9–10).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A streaming service's watch-party page shows the people watching together and a message composer:
The team builds it with React Compiler 1.0 (babel-plugin-react-compiler 1.0.0, default options).
With 80 viewers in the party, one character typed into the composer causes how many renders of ViewerTile and Avatar together?
import { createContext, use, useState } from 'react'; const PartyContext = createContext(null); function WatchParty({ viewers, me }) { const [draft, setDraft] = useState(''); return ( <PartyContext value={{ draft, setDraft, me }}> <Composer /> <ul>{viewers.map((v) => <ViewerTile key={v.id} v={v} />)}</ul> </PartyContext> );} function Composer() { const { draft, setDraft } = use(PartyContext); return <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />;} function ViewerTile({ v }) { const { draft, me } = use(PartyContext); return ( <li> <Avatar src={v.avatar} /> {v.name}{v.id === me && draft !== '' ? ' is typing…' : ''} </li> );} function Avatar({ src }) { return <img src={src} alt="" width={32} height={32} />;}Question 2 of 7
A box office's release 5.2 lets staff add a note to any of 150 held seats, with a bar that warns while a note is unsaved:
Typing a note has become sluggish. The goal: after the first keystroke, each keystroke in a seat's note renders only that SeatRow, and the bar still shows while a note is unsaved.
Which change meets it?
import { createContext, memo, use, useState } from 'react'; const NotesContext = createContext(null); function SeatPage({ seats }) { const [notes, setNotes] = useState({}); const setNote = (id, text) => setNotes((n) => ({ ...n, [id]: text })); return ( <NotesContext value={{ notes, setNote }}> <UnsavedBar /> {seats.map((s) => <SeatRow key={s.id} seat={s} />)} </NotesContext> );} const SeatRow = memo(function SeatRow({ seat }) { const { notes, setNote } = use(NotesContext); return ( <li id={seat.id}> {seat.label} <textarea value={notes[seat.id] ?? ''} onChange={(e) => setNote(seat.id, e.target.value)} /> </li> );}); function UnsavedBar() { const { notes } = use(NotesContext); return Object.values(notes).some(Boolean) ? <p role="status">Unsaved notes</p> : null;}Question 3 of 7
A shop embeds a support-chat widget. The shop's React app is mounted with createRoot(document.getElementById('root')). The widget is a separate bundle on the same page, mounted with createRoot(document.getElementById('chat-root')); #chat-root is a sibling of #root, not inside it. The widget re-renders the conversation on every keystroke:
The conversation holds 150 messages, and Emotes renders each message's emotes. In throttled Chromium with web-vitals' attribution build, a shopper types one character into the chat box.
What does the attribution's longestScript.entry.invoker hold?
import { useState } from 'react'; function ChatWidget({ history }) { const [draft, setDraft] = useState(''); return ( <section> <ol>{history.map((m) => <Bubble key={m.id} m={m} />)}</ol> <textarea id="chat-input" value={draft} onChange={(e) => setDraft(e.target.value)} /> </section> );} function Bubble({ m }) { return <li><Emotes text={m.text} /></li>;}Question 4 of 7
A payments dashboard's refund form, release 9.2, mounted with createRoot(document.getElementById('root')), adds a button that fills in every line's amount:
The form has 90 lines, r1 to r90, and Receipt renders each line's receipt. Agents say the button feels stuck. In Chromium with the CPU throttled 4×, the on-call engineer clicks Refund every line in full once and reads web-vitals' attribution build, the network panel and a render counter:
interactionType pointer
longestScript event-listener DIV#root.onclick · subpart processing-duration
fetch calls 0 during the click
RefundLine 90 renders before the next paint
Which hypothesis does this readout support?
import { createContext, use, useState } from 'react'; const RefundContext = createContext(null); function RefundPage({ lines }) { const [amounts, setAmounts] = useState({}); const setAmount = (id, value) => setAmounts((a) => ({ ...a, [id]: value })); const refundAll = () => setAmounts(Object.fromEntries(lines.map((l) => [l.id, l.total]))); return ( <RefundContext value={{ amounts, setAmount }}> <button onClick={refundAll}>Refund every line in full</button> <RefundTotal /> {lines.map((l) => <RefundLine key={l.id} line={l} />)} </RefundContext> );} function RefundLine({ line }) { const { amounts, setAmount } = use(RefundContext); return ( <div id={line.id}> <Receipt line={line} /> <input value={amounts[line.id] ?? ''} onChange={(e) => setAmount(line.id, e.target.value)} /> </div> );} function RefundTotal() { const { amounts } = use(RefundContext); const total = Object.values(amounts).reduce((s, v) => s + (Number(v) || 0), 0); return <p>Refund total: {total.toFixed(2)}</p>;}Question 5 of 7
In the interview you are given EXH-3030's page and asked: "INP doubled after a release.
How do you find the cause?" What do you answer, in at most 150 words?
Question 6 of 7
A streaming service's live comments panel, release 12.1, shows 150 comments beside the player and sends the viewer's comment on Enter:
Emotes renders each comment's emotes. In Chromium with the CPU throttled 4×, a viewer has typed "gg" into the composer and presses Enter.
In which event's processing window do the 150 CommentRow renders run?
import { createContext, use, useState } from 'react'; const ChatContext = createContext(null); function LiveChat({ comments }) { const [draft, setDraft] = useState(''); const [mine, setMine] = useState([]); const send = () => { setMine((m) => [...m, draft]); setDraft(''); }; return ( <ChatContext value={{ draft, setDraft, send }}> <ul>{comments.map((c) => <CommentRow key={c.id} c={c} />)}</ul> <ol>{mine.map((t, i) => <li key={i}>{t}</li>)}</ol> <Composer /> </ChatContext> );} function CommentRow({ c }) { const { draft } = use(ChatContext); return <li className={draft !== '' && c.mentionsMe ? 'mention' : ''}><Emotes text={c.text} /></li>;} function Composer() { const { draft, setDraft, send } = use(ChatContext); const onKeyDown = (e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }; return <textarea id="composer" value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={onKeyDown} />;}Question 7 of 7
ExamHall's essay page keeps each draft in its card. Release 2026.10.02 adds a plagiarism fingerprint that the essay box refreshes on every keystroke:
The page has 12 essay questions, and fingerprint compares the essay with 40,000 reference passages. Candidates on Chromebooks say typing lags again. Release 2026.09.30 is the same page without the setPrint(fingerprint(…)) line.
Which candidate CI check is red on 2026.10.02 and green on 2026.09.30?
import { memo, useState } from 'react'; function EssayPage({ questions }) { return questions.map((q) => <EssayCard key={q.id} q={q} />);} const EssayCard = memo(function EssayCard({ q }) { const [text, setText] = useState(''); const [print, setPrint] = useState(''); const onChange = (e) => { setText(e.target.value); setPrint(fingerprint(e.target.value)); }; return ( <section id={q.id}> <textarea value={text} onChange={onChange} /> <small>{print}</small> </section> );});Go deeper
Pro tips
- Read the three INP parts before guessing. web-vitals' attribution build reports
inputDelay,processingDurationandpresentationDelay; a large processing duration points at your listeners and the renders they trigger (curriculum row's own tip). When: step 4, before you open the code. Status: EXECUTED. - Throttle against your own machine. Chrome DevTools' CPU throttling is relative to the laptop it runs on, so a 4× setting on a fast laptop is still not a school Chromebook; check the field data by device before calling a fix done. When: step 8, after a lab fix looks fine. Verify: Chrome DevTools' Performance reference. Status: DOC.
Source
Browser and library behaviour can change: the facts below are as of 2026-10-06. No item is keyed on a source; every key is executed. Valid as of 2026-10-06.
- web.dev, Interaction to Next Paint (INP): https://web.dev/articles/inp (
web-dev-inp.txt), the curriculum row's first source. Supports step 1. - web.dev, Optimize Interaction to Next Paint: https://web.dev/articles/optimize-inp (
web-dev-optimize-inp.txt), the row's second source. Quoted: "The processing duration, which consists of the time it takes for event callbacks to run to completion." Supports steps 4–5. - GoogleChrome web-vitals README: https://github.com/GoogleChrome/web-vitals (
web-vitals-readme.txt), the row's third source, and the 6.2.3 package run here. Quoted: "The INP subpart where the longest script ran." Supports steps 4 and 6. - web.dev, Find slow interactions in the field: https://web.dev/articles/find-slow-interactions-in-the-field (
web-dev-find-slow-interactions.txt). Quoted: "The attribution build of the web-vitals library exposes an array of LoAF entries under the longAnimationFrameEntries key of the attribution object." Supports step 6. - react.dev, useSyncExternalStore: https://react.dev/reference/react/useSyncExternalStore (
react-use-sync-external-store.txt). Quoted: "The getSnapshot function should read a snapshot of the data from the store." Supports step 10. - react.dev, React Compiler introduction: https://react.dev/learn/react-compiler/introduction (
react-compiler-intro.txt). Quoted: "React Compiler's automatic memoization is primarily focused on improving update performance (re-rendering existing components)". Supports step 11. - react.dev, createContext: https://react.dev/reference/react/createContext (
react-createcontext.txt). Supports step 3. - Chrome DevTools, Performance features reference: https://developer.chrome.com/docs/devtools/performance/reference (
chrome-perf-reference.txt). Quoted: "Throttling is relative to your computer's capabilities." Supports step 8 and tip 2.
Related lessons
Checked against the tools and docs as of 2026-10-06.