Skip to content

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?

Code for this question
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;}
Pick one

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

Read the symptom. INP rose after one release and most on slow devices; field data names the page and the interaction type, not the code.

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?

Code for this question
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} />;}
Pick one

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?

Code for this question
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;}
Pick one

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?

Code for this question
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>;}
Pick one

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?

Code for this question
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>;}
Pick one

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?

Code for this question
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} />;}
Pick one

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?

Code for this question
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>  );});
Pick one

Go deeper

Pro tips
  1. Read the three INP parts before guessing. web-vitals' attribution build reports inputDelay, processingDuration and presentationDelay; 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.
  2. 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.

Checked against the tools and docs as of 2026-10-06.