Runtime performance
Keeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
The problem: a fast page that feels slow
Picture an analytics dashboard. It loads in 1.2 seconds, its Largest Contentful Paint is green, and the team is proud of it.
Then a user types "rev" into the filter box above a table of 8,000 rows. Each key takes almost half a second to appear. The user types faster than the page can answer, letters show up in bursts, and they decide the product is slow.
Nothing about loading is wrong here. The trouble starts after load, while the page is running. Every keypress runs a handler that filters 8,000 rows, sorts them, and re-renders the whole table. That work takes 420 ms, and for those 420 ms the browser can't do anything else: it can't paint the letter, can't run the next key's handler, can't even update the blinking caret.
Runtime performance is the part of performance that covers this: how quickly the page responds while the user is using it. Load performance asks "how fast does the page show up?" Runtime performance asks "how fast does the page answer me, every time I touch it, for the whole session?"
Why one slow handler freezes everything
The browser runs your JavaScript, computes styles, lays out the page, and paints, all on one thread for each page: the main thread. It does one thing at a time. A task is one unit of work the main thread picks from a queue, such as "run this click handler" or "run this timer callback".
Once a task starts, it runs to the end. The browser can't stop it halfway to paint a frame or handle a new click.
So a 420 ms handler is a 420 ms freeze. Clicks and keys that arrive during it wait in a queue, and animations driven by JavaScript stop. If the screen refreshes 60 times a second, that's about 25 frames the browser had to skip. The lesson on browser architecture covers the event loop in detail; here we only need the rule that follows from it: a task that runs long blocks everything behind it.
Why teams care: users notice, and so does Google
Users feel delay long before they can name it. Around 100 ms, a response feels instant. Past 200 to 300 ms it feels sluggish, and past a second people click again, which queues even more work.
Google measures this as INP, Interaction to Next Paint, one of the three Core Web Vitals. INP looks at the clicks, taps, and key presses on a page and reports roughly the slowest one. A good INP is 200 ms or less, at the 75th percentile of real visits. Our dashboard, at 420 ms per key, is in the "needs improvement" band (200 to 500 ms) and close to "poor".
What this lesson gives you
By the end you will be able to say where a slow interaction's time goes, using the three parts of INP. You will know the fixes: do less, split the work and yield, move it to a Web Worker, skip needless re-renders, and delay what can wait. And you will know how to prove a fix with the DevTools Performance panel.
The mental model: one lane, tasks, and frames
Think of the main thread as a one-lane road. Tasks are cars on it: some are tiny (a 2 ms timer), some are trucks (a 400 ms filter).
The browser's rendering work, the thing that puts a new frame on screen, is also a car in the same lane. It can only get through between other cars. One slow truck holds up everyone behind it, including the frame.
Keep two units in your head: tasks and frames. Your code runs inside tasks. The user sees frames. Runtime performance is the art of keeping tasks short so frames can keep coming.
The frame budget
A screen that refreshes 60 times a second shows a new frame every 16.7 ms. On a 120 Hz phone or laptop, that drops to 8.3 ms. When the browser decides to make a frame, it runs a fixed sequence of steps on the main thread: requestAnimationFrame callbacks, style (which CSS rules apply), layout (where every box goes), paint (which pixels each box draws), and then it hands the result to the compositor thread, which assembles layers and shows them.
During an animation or a drag, all of that has to fit in the budget, which leaves about 10 ms of a 60 Hz frame for your JavaScript. If the frame isn't ready in time, the screen shows the old one again: a dropped frame, which the user sees as jank.
Long tasks and the 50 ms rule
Outside animations, the browser doesn't need a frame every 16 ms; nothing is moving. What matters then is how fast it can answer the next input. That's where the long task comes in: any task on the main thread that runs longer than 50 ms. The browser's own APIs and DevTools use exactly this line.
Why 50 ms? An input can arrive at any moment, including the start of a task. If no task runs longer than 50 ms, an input never waits more than 50 ms to start.
That leaves 50 ms to handle it and paint, so the whole response fits in about 100 ms, which people feel as instant. This idea comes from Google's RAIL model, and it still holds.
| Number | What it means | Where you meet it |
|---|---|---|
| 8.3 ms | Frame budget at 120 Hz | Smooth scrolling and drags on modern phones |
| 16.7 ms | Frame budget at 60 Hz | Animations, requestAnimationFrame |
| 50 ms | A task longer than this is a long task | Long Tasks API, DevTools red corners, Total Blocking Time |
| 100 ms | Response feels instant | RAIL model, the goal for any input |
| 200 ms | Good INP at the 75th percentile | Core Web Vitals |
| 500 ms | Above this, INP is poor | Core Web Vitals |
What doesn't need the main thread
Plain scrolling, and CSS animations of only transform and opacity, run on the compositor thread, so they keep moving during a long task. A CSS spinner keeps spinning during a freeze; a JavaScript-driven one stops. Network requests, image decoding, and Web Workers also run on other threads.
So the job has a clear shape: keep each task under 50 ms, keep frames coming during motion, and push work that doesn't need the DOM elsewhere.
Check your understanding · Exercise 1
A page runs one 180 ms task right after load. Which of these happen while it runs? Select all that apply.
How a slow interaction adds up: the three parts of INP
INP measures one thing per interaction: the time from the user's input to the next frame the browser paints after it. An interaction is a click, a tap, or a key press. Hovering and scrolling don't count. The clock starts when the input happens and stops when the next frame shows up on screen, so INP measures what the user sees, not when your handler finished.
That time always splits into the same three parts, in order. Knowing which part is big tells you which fix to reach for, so learn them well.
- Input delay: from the input until its first event handler starts. The input waits here when the main thread is busy with some other task, like a timer, a third-party script, or hydration.
- Processing time: the time your event handlers take to run. For a key press that's
keydown,beforeinput,input, andkeyuphandlers; for a tap it'spointerdown,pointerup, andclick. In a framework, this includes the state update and the render it triggers if they run synchronously. - Presentation delay: from the end of the handlers until the frame is painted. The browser has to run
requestAnimationFramecallbacks, recalculate style, lay out, and paint. A huge DOM or a style change that touches every element makes this part large.
A worked example with real numbers
Back to the dashboard. The user presses v at time 0. An analytics script happens to be running a 60 ms task that started 20 ms earlier, so the key waits 40 ms: that's the input delay.
Then the input handler sets React state, React filters 8,000 rows and renders the table synchronously: 320 ms of processing. Then the browser restyles and lays out a table with 8,000 rows: 60 ms of presentation delay.
So this interaction's INP is 40 + 320 + 60 = 420 ms. Processing is 76 percent of it, so that's where to start. If input delay had been the big part, changing the handler wouldn't help at all; you'd need to find the task that was blocking. Field tools like the web-vitals library's attribution build report these three parts for you, so you don't have to guess.
Which interaction becomes the page's INP
INP reports one number per visit: the slowest interaction, except that pages with many interactions ignore one of the worst for every 50, so one fluke doesn't define the page. Across visits, Google reports the 75th percentile. So three out of four visits must have their worst interaction at 200 ms or less.
Averages hide the problem. If 49 clicks take 40 ms and the "Export" button takes 900 ms, INP is 900 ms for anyone who exports. Find and fix the slowest interaction, not the common path.
Check your understanding · Exercise 2
An interaction shows 15 ms input delay, 25 ms processing, and 310 ms presentation delay. Which fixes target the biggest part? Select all that apply.
Finding the slow work: long tasks, Long Animation Frames, and TBT
You can't fix what you can't see. There are three ways to see main-thread trouble, and each answers a different question: the Long Tasks API in the field, Long Animation Frames in the field with much better detail, and Total Blocking Time in the lab.
The Long Tasks API
A PerformanceObserver that listens for entries of type longtask gets one entry for every task over 50 ms, with its start time and duration. It's simple and tells you that the main thread was blocked and for how long. Its weakness is attribution: it tells you which frame (the page or an iframe) owned the task, but not which script or function. "Something ran for 230 ms" isn't enough to fix anything.
Long Animation Frames (LoAF)
The Long Animation Frames API, shipped in Chrome 123 in 2024, looks at frames instead of tasks. A long animation frame is one where the time from its start to its paint is over 50 ms. That framing matters: a frame can be slow because of one long task, or because of several medium tasks plus slow rendering. LoAF catches both, which is closer to what INP measures.
Each entry carries the pieces you need to act. blockingDuration is how long the frame blocked input. renderStart and styleAndLayoutStart show how much went to rendering. And scripts lists every script over 5 ms that ran in the frame, with its sourceURL, its sourceFunctionName, what invoked it (such as a click listener or a resolved promise), and forcedStyleAndLayoutDuration, the time it spent forcing layout by reading geometry after a write.
new PerformanceObserver((list) => {
for (const frame of list.getEntries()) {
if (frame.duration < 100) continue;
const worst = [...frame.scripts].sort((a, b) => b.duration - a.duration)[0];
sendToAnalytics({
duration: frame.duration,
blocking: frame.blockingDuration,
script: worst?.sourceURL,
fn: worst?.sourceFunctionName,
invoker: worst?.invoker,
forcedLayout: worst?.forcedStyleAndLayoutDuration,
});
}
}).observe({ type: "long-animation-frame", buffered: true });
scripts[0], say who to blame: the export button's click handler in app.js, which also forced 74 ms of layout.LoAF is Chromium only for now, which is fine for diagnosis: scripts that are slow in Chrome are usually slow everywhere. The web-vitals library's attribution build joins the dots: for the interaction that set INP, it reports the three parts and the overlapping long animation frames.
Total Blocking Time in the lab
Lighthouse can't click your page like a real user, so it can't measure INP. It reports Total Blocking Time (TBT) instead: for every long task during load, it adds up the part beyond 50 ms. A 120 ms task adds 70 ms; a 40 ms task adds nothing.
TBT is a good lab stand-in for how blocked the page is during load, but it says nothing about a slow handler on the Export button. Use it to catch regressions in CI, and use field INP and LoAF to learn what users hit.
| Tool | Where | Tells you | Misses |
|---|---|---|---|
| Long Tasks API | Field, all Chromium | That a task over 50 ms ran, and how long | Which script; rendering cost |
| Long Animation Frames | Field, Chrome 123+ | Slow frames, scripts, forced layout, render time | Non-Chromium browsers |
| INP with attribution | Field | The worst interaction and its three parts | Work that isn't tied to an interaction |
| Total Blocking Time | Lab (Lighthouse) | Main-thread blocking during load | Anything after load, real input |
| DevTools Performance | Lab, your machine | Every task, call stack, and frame | What real users on slow phones see |
Check your understanding · Exercise 3
Field INP is 480 ms on the product page, but Lighthouse shows TBT of 90 ms. Which statements are true? Select all that apply.
Splitting the work: chunking and yielding
Often the work can't shrink. You really do need to index 20,000 items or apply a filter to every row. But it doesn't have to run as one task.
Split it into chunks, and between chunks end the task so the browser can handle waiting input and paint. This is called yielding to the main thread. The total work stays the same; what changes is that nothing waits behind it for long.
The old way: setTimeout chunks
The oldest trick is setTimeout(next, 0): do a chunk of work, schedule the rest as a new task, and return. The browser can handle a queued click or paint before the next chunk. It works everywhere.
It has two costs. First, the rest of your work goes to the back of the task queue: if a third-party script queued five tasks in the meantime, they all run first, so the whole job can take much longer. Second, after five nested timeouts browsers clamp the delay to at least 4 ms, so yielding after every tiny item wastes time. Chunk by time, not by item: yield every 40 ms or so.
The modern way: scheduler.yield()
await scheduler.yield() gives the main thread back, lets input and rendering happen, and then resumes your function ahead of other queued tasks of the same priority. You get the responsiveness of yielding without the penalty of going to the back of the line. It also reads naturally: an await in the middle of a loop.
Support is the catch. scheduler.yield() shipped in Chrome 129 (2024) and Firefox 142 (2025); Safari doesn't support it yet. So always wrap it with a fallback, as the browser architecture lesson showed.
function yieldToMain(): Promise<void> {
if (globalThis.scheduler?.yield) return scheduler.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function filterRows(rows: Row[], query: string, signal: AbortSignal) {
const out: Row[] = [];
let last = performance.now();
for (const row of rows) {
if (row.name.includes(query)) out.push(row);
if (performance.now() - last > 40) {
await yieldToMain();
if (signal.aborted) return null; // a newer query arrived
last = performance.now();
}
}
return out;
}
AbortSignal after each yield so stale work stops.Note the signal check. Once work is split, other code runs in the gaps, including the next keystroke's handler. Without a way to cancel, filters for "re" and "rev" interleave and the older one may overwrite the newer result.
Yielding inside an event handler
For INP, the most useful yield is the first one. In a handler, do the visible update first (check the box, show the typed letter, open the menu), then yield, then do the rest: send analytics, save to storage, recompute other panels. The browser paints right after the first chunk, and that paint is where INP stops counting.
Other scheduling tools, and one to stop using
scheduler.postTask(fn, { priority })queues a task with a priority:user-blocking,user-visible(the default), orbackground. Useful to push non-urgent work below everything else. Supported in Chrome (since 94) and Firefox 142, but not Safari, so it needs the same fallback.requestIdleCallback(fn)runs work when the browser has nothing else to do in a frame. Good for truly optional work like prefetching or warming caches. Safari doesn't support it yet (only behind a flag in its preview builds), so feature-check it and fall back tosetTimeout.navigator.scheduling.isInputPending()let code ask "is input waiting?" and yield only then. Chrome no longer recommends it: it can miss pending input, and input isn't the only reason to yield (rendering needs the thread too). Usescheduler.yield()instead.
Decision Which tool should split a 600 ms job on the main thread?
What we pick: For work that touches the DOM or framework state, use scheduler.yield() with the setTimeout fallback. For pure computation on a lot of data, a worker is better, as the next chapter shows. Use postTask with background priority for work nobody is waiting on.
Check your understanding · Exercise 4
A checkbox handler updates the box, recalculates 12 charts (180 ms), and logs analytics (20 ms). What gives the best INP? Select all that apply.
Moving work off the main thread: Web Workers
Yielding shares the main thread. A Web Worker gives you a second thread: a separate JavaScript file with its own event loop and memory. It can use fetch, timers, IndexedDB, and WebAssembly, but it can't touch the DOM, window, or your framework's state. The page and the worker talk only by sending messages.
That rule tells you what belongs in a worker: computation on data, where the result is small or can be shown later. Parsing a 5 MB CSV, full-text search over 50,000 items, diffing two documents, syntax highlighting, image filters, compressing a file before upload, decoding a binary protocol. The main thread then only sends the input and renders the answer.
postMessage copies, so keep messages small or transfer buffers.Messages, copies, and transfers
postMessage copies its argument with the structured clone algorithm. It handles objects, arrays, Map, Date, typed arrays, and more, but not functions or DOM nodes. The copy happens on the sending thread, so sending a 40 MB object from the main thread can itself be a long task. This is the most common way a worker fails to help: the work moved, but the copying stayed.
- Send less. Load the data inside the worker with
fetchinstead of sending it from the page. Keep the big dataset in the worker and send only queries and small results back. - Transfer, don't copy. Pass an
ArrayBufferin the transfer list:worker.postMessage(buf, [buf]). Ownership moves to the worker in constant time and the page's copy becomes empty.ImageBitmap,MessagePort, andOffscreenCanvastransfer too. - Return the minimum. Send back the 50 matching ids, not the 50,000 rows with a flag on each.
Making workers pleasant: Comlink
Raw postMessage code turns into a little protocol: message types, ids to match replies to requests, error handling. Comlink, a small library from the Chrome team (about 1.2 KB gzipped), hides that. You expose an object in the worker and wrap the worker on the page; every method call becomes a promise.
// search.worker.ts
import * as Comlink from "comlink";
let index: SearchIndex | null = null;
const api = {
async load(url: string) { index = buildIndex(await (await fetch(url)).json()); },
search(query: string) { return index ? index.search(query).slice(0, 50) : []; },
};
Comlink.expose(api);
export type SearchApi = typeof api;
// page.ts
import * as Comlink from "comlink";
import type { SearchApi } from "./search.worker";
const worker = new Worker(new URL("./search.worker.ts", import.meta.url), { type: "module" });
const search = Comlink.wrap<SearchApi>(worker);
await search.load("/products.json");
const hits = await search.search("rev"); // runs on the worker thread
What workers cost
- Startup: a new worker fetches and parses its script, often tens of milliseconds. Create it once and reuse it.
- Memory: each worker is a separate JavaScript environment. For parallel work, a small pool sized from
navigator.hardwareConcurrencyis plenty. - Latency: each round trip is asynchronous. For a 2 ms calculation, a worker adds more delay than it saves.
- Stale replies: like chunked work, worker replies can arrive out of order. Tag each request with an id and drop answers to old queries.
Two related tools: OffscreenCanvas lets a worker draw a heavy chart or map entirely off the main thread, and Partytown runs some third-party scripts in a worker, with limits for scripts that touch the DOM a lot.
Check your understanding · Exercise 5
Which of these are good candidates for a Web Worker? Select all that apply.
Doing less work: memoization, re-renders, and debouncing
The cheapest work is work you skip. In component frameworks, much main-thread time goes to re-rendering components whose output didn't change. The examples use React, but every framework has to decide what to recompute when state changes.
Why React re-renders more than you expect
When a component's state changes, React calls that component again, and by default every component it renders, all the way down. It doesn't check whether their props changed. Then it compares the new output to the old and touches the DOM only where something differs. That comparison is cheap per component, but calling 2,000 components to learn that 1,990 are unchanged isn't.
So the cost of a state change depends on where the state lives: the global versus local state lesson calls this the blast radius. The first fix is structural, not memoization.
Structural fixes first
- Move state down. Keep the search text inside the
SearchBox, not inDashboard. Only the box re-renders as the user types. - Lift content up. A component's
childrenwere created by its parent, so they don't re-render when the component's own state changes. ACollapsiblecan wrap an expensive chart without re-rendering it. - Split contexts. Every consumer of a context re-renders when its value changes. Put fast-changing values (cursor position, typing) in their own context or in a store with selectors, so a component subscribes only to the slice it reads.
- Render less. A list of 8,000 rows should render about 40. The virtualization lesson covers this; it fixes processing and presentation delay at once.
Memoization: React.memo, useMemo, useCallback
Memoization means remembering a result and reusing it while the inputs are the same. React gives you three tools:
React.memo(Component)skips re-rendering a component when its props are shallowly equal to last time.useMemo(() => compute(a, b), [a, b])caches an expensive value, like a sorted array of 10,000 rows.useCallback(fn, deps)keeps a function's identity stable so it doesn't break a child'smemo.
The classic mistake is a memo that never hits. If the parent passes style={{ padding: 8 }} or onSelect={() => pick(id)}, that's a new object or function on every render, so the comparison fails and the child re-renders anyway. Every non-primitive prop must be stable.
The React Compiler changes this. It's a build-time tool that analyzes components and inserts memoization automatically, at a finer grain than people write by hand. It reached a stable 1.0 release in October 2025 and works with React 17 and later, though it expects your components to follow the Rules of React (no mutating props or state during render).
With it enabled, hand-written useMemo and useCallback become mostly unnecessary. It doesn't fix structure, though: state in the wrong place and 8,000 rows in the DOM are still your problem.
Marking work as less urgent
React 18 added a way to say "this update can wait". Wrap a state update in startTransition, or read a value through useDeferredValue, and React renders it at lower priority.
With this concurrent rendering, React works in slices of about 5 ms and yields between them, so a new keystroke can interrupt a half-done table render. The input stays responsive and the table catches up. It's yielding, built into the framework.
Debouncing expensive handlers
Sometimes the handler is fine; it just runs too often. A filter that runs on every keystroke of "revenue" runs seven times. Debouncing waits until the input has been quiet for, say, 150 ms and runs once.
Throttling runs at most once per interval, which suits scroll and resize. The debounce and throttle lesson covers both in depth.
For INP, split the handler in two. The visible part, showing the typed letter, stays immediate. The expensive part, filtering and rendering results, is what you debounce, defer, or move to a worker.
Check your understanding · Exercise 6
A Row is wrapped in React.memo, yet all 500 rows re-render on every parent update. Which could be the cause? Select all that apply.
Profiling with the DevTools Performance panel
Field data tells you which interaction is slow and roughly why. The Chrome DevTools Performance panel shows you exactly why, down to the function. Here is a routine that works for almost every runtime problem.
Set up a realistic run
- Use an incognito window, so extensions don't add tasks to the trace.
- Turn on CPU throttling (4x or 6x). Your laptop is several times faster than a mid-range Android phone; without throttling, a 300 ms problem looks like 60 ms.
- Glance at the panel's landing view: it shows live LCP, CLS, and INP as you use the page. Do the slow interaction a few times and note its INP.
- Press Record, do the one slow interaction, and stop. Short traces are easier to read.
What to look at, in order
- The Interactions track. Each interaction is a bar. Hovering it shows the total and its split into input delay, processing, and presentation delay. Long ones are marked. This tells you which of the three parts to chase.
- The Main track. Tasks are grey bars along the top; long tasks have a red triangle in the corner and red stripes over the part beyond 50 ms. Below each task is the flame chart: who called whom, with width as time. Look for the widest bar near the bottom of the stack that's your code: that's where time goes.
- Purple and green. Purple blocks are style and layout; green is paint. A tall stack of small purple blocks inside your script, flagged as forced reflow, means layout thrashing: reading
offsetHeightafter writing styles, in a loop. - The Bottom-up tab. It totals self time per function across the selection. If
Array.prototype.sortor a date-formatting function sits at the top, you've found your target even if it's called from many places. - Insights in the sidebar. Recent Chrome versions summarize likely problems next to the trace, such as an INP breakdown by phase, forced reflows, and a large DOM. The names and the list change between versions; use them as hints, then confirm in the trace.
Framework profilers
Chrome's flame chart shows React's internals, which are hard to map to your components. The React DevTools Profiler shows which components rendered, how long each took, and, with the right setting, why ("props changed: onSelect"). The Performance panel finds that rendering is the problem; the React Profiler finds which component and prop.
Mistakes that waste an afternoon
- Profiling a development build. React's development mode adds checks that can make rendering several times slower. Profile a production build, with source maps for readable names.
- Profiling without throttling, then deciding "it's fine on my machine".
- Measuring once. Run the interaction three to five times; the first run includes compiling code and warming caches.
Check your understanding · Exercise 7
A trace of a slow click shows a 280 ms task. Inside your onSort function, there are 300 tiny purple Layout blocks, each after a style write. What's going on and what fixes it? Select all that apply.
A worked INP fix, start to finish
Let's fix the dashboard from chapter 1 properly, one measured step at a time. The setup: a React page with a filter input and a table of 8,000 rows. Field INP is 420 ms at the 75th percentile, and the web-vitals attribution data says the worst interaction is a keydown on the filter input.
Step 1: measure and split
With 4x CPU throttling and a production build, a recorded keystroke shows input delay 40 ms, processing 320 ms, presentation delay 60 ms. The flame chart shows the processing is the input handler calling setQuery, then React rendering Dashboard, which filters and sorts 8,000 rows (90 ms) and renders 8,000 Row components (230 ms). The 40 ms input delay is an analytics timer that fires every few seconds.
Step 2: separate the urgent update from the expensive one
The user needs to see their letter now; the table can follow. Keep the input's text as urgent state and give the table a deferred copy. React renders the input first, paints, and renders the table at lower priority in 5 ms slices that a new keystroke can interrupt.
function Dashboard({ rows }: { rows: Row[] }) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const visible = useMemo(
() => filterAndSort(rows, deferredQuery),
[rows, deferredQuery],
);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultsTable rows={visible} />
</>
);
}
const ResultsTable = memo(function ResultsTable({ rows }: { rows: Row[] }) {
return <VirtualList items={rows} rowHeight={36} renderRow={(r) => <RowView row={r} />} />;
});
useDeferredValue makes the table lag the input; memo stops the table re-rendering on the urgent pass, when visible hasn't changed yet.Result: processing for the keystroke drops to about 15 ms, because the urgent render only touches the input. The table's work still happens, in interruptible slices. INP falls to about 115 ms, but the page still burns CPU on every key.
Step 3: render 40 rows, not 8,000
Virtualizing the table (the VirtualList above) renders only the rows in view plus a buffer. Rendering drops from 230 ms to about 6 ms, and the DOM shrinks from about 100,000 nodes to about 500, cutting presentation delay from 60 ms to about 12 ms for every interaction on the page.
Step 4: move filtering off the thread if the data grows
Filtering and sorting still costs 90 ms per key on the main thread, in the deferred pass. At 8,000 rows that's acceptable with useDeferredValue. If the product plans for 200,000 rows, move the dataset and filterAndSort into a worker with Comlink, send the query, get back the ids of the matches, and drop replies to outdated queries.
Also split the analytics timer's 60 ms of work into short background priority tasks, so a key never waits long behind it. That removes the 40 ms input delay spike. In the lab trace, the keystroke's INP is now about 32 ms: 5 ms input delay, 15 ms processing, 12 ms presentation delay.
Step 5: verify in the field
Lab numbers prove the mechanism; field numbers prove the outcome. Ship behind a flag, compare the two groups' INP at the 75th percentile, and watch LoAF reports. Chrome's public CrUX data is a rolling 28-day window, so it moves slowly.
Try it: the main-thread lab
The lab below runs a click handler with a chosen amount of work and a second click that arrives 60 ms later, while a background script runs a 20 ms task every 100 ms. Pick a strategy and watch what happens to both clicks' INP, how many 60 Hz frames are dropped, and how long the whole job takes.
Notice that no strategy makes the work free: chunking and yielding trade total time for responsiveness, and the worker trades a message round trip for an idle main thread.
Check your understanding · Exercise 8
In the worked fix, why did useDeferredValue cut INP so much even though the table still rendered 8,000 rows? Select all that apply.
Failure modes, real products, and the interview
Runtime problems rarely show up in development, where the laptop is fast, the data is small, and no third-party scripts run. They show up in production, on mid-range phones, with real data. Here are the usual suspects.
What goes wrong in production
- Hydration as one giant task. Hydrating 3,000 components in one go blocks input right when users start clicking: high input delay on early interactions. Fix with smaller client bundles, partial hydration, or islands.
- Third-party scripts. Tag managers, chat widgets, and A/B tools run their own long tasks; LoAF's
sourceURLpoints at them. Load them later or with lower priority. - Yield without cancel. Chunked work that can't be cancelled piles up: five filter jobs interleave and the oldest finishes last. Use an
AbortSignalor a version number. - Memoization that never hits.
memoeverywhere, but props are new objects each render. The React Profiler shows "props changed" on every commit. - Fast INP, frozen page. A handler paints a spinner and then runs 3 seconds of unbroken work. INP looks fine, but every following input waits. Watch long tasks and LoAF too, not just INP.
In real products
The techniques in this lesson aren't exotic. Frameworks build them in, and the most demanding web apps combine all of them: a small DOM, work split into slices, and heavy computation on other threads.
- React builds yielding into its scheduler: concurrent renders work in slices of about 5 ms and give the thread back between them, which is what makes
useTransitionanduseDeferredValuework. - The Monaco editor, the code editor inside VS Code, runs language services such as TypeScript checking in web workers, so typing stays smooth while analysis runs.
- Squoosh, Google's image compression app, runs its image codecs in workers through Comlink, so dragging the comparison slider never waits for an encode.
Using it in a system design interview
Bring it up whenever the design has a big list, a live feed, a rich editor, a dashboard, or search-as-you-type. Say the budget out loud: "every keystroke paints within 100 ms, and no task runs over 50 ms". Then show what you'll move off the main thread.
- What to draw: the main thread as one lane with the work on it, and a worker box beside it holding the heavy data. Mark which updates are urgent (the input) and which can lag (results, charts).
- Junior: knows long JavaScript blocks clicks; names debouncing, virtualization, memoization.
- Mid-level: explains INP's three parts, chunking with
scheduler.yield()and a fallback, and when a worker helps or doesn't. Avoids needless re-renders by state placement. - Senior: designs data flow around the main thread from the start, plans field measurement with INP attribution and LoAF, and names the trade-offs: cancellation, copy cost, total time versus responsiveness.
Think first
A chat app with 2,000 messages in view gets slow when a new message arrives every second. INP is fine, but scrolling stutters. What's likely happening, and what do you fix?
Each new message probably re-renders the whole list and re-parses every message's markdown. Those long tasks land during scrolling and drop frames; INP is fine because nobody is clicking.
Fixes: key and memoize messages so only the new one renders, cache parsed markdown by id, virtualize the list, and render at most once per frame, not once per socket event.
Summary
- The main thread does one task at a time and never interrupts one, so any task over 50 ms delays every input behind it.
- INP is the time from input to the next paint, split into input delay, processing time, and presentation delay. Measure the split before choosing a fix.
- Find slow work with Long Animation Frames and INP attribution in the field, Total Blocking Time in CI, and the DevTools Performance panel with CPU throttling on your machine.
- Split long work into time-based chunks and yield with
scheduler.yield(), falling back tosetTimeout. Make chunked work cancellable. - Move heavy computation that doesn't need the DOM into a Web Worker, keep the data there, and transfer buffers instead of copying.
- Skip work: put state where it's used, render fewer nodes, memoize hot components (or let the React Compiler do it), and debounce the expensive part of a handler, never the visible part.
- Do the visible update first and defer the rest; but a good INP with a frozen page afterward is still a bug.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
A search box over 50,000 items lags on every keystroke. Walk me through how you'd fix it.
First I'd measure: record a keystroke in the Performance panel with CPU throttling, and read the INP split. I expect most of it in processing (filtering 50,000 items and rendering results) and some in presentation delay (a big results DOM).
Then I'd separate urgent from expensive. The input's value updates immediately; the results use a deferred value or a debounced query. The results list is virtualized so only visible rows render. The dataset and the filter move into a Web Worker, built with Comlink, that loads the data itself and returns the top 50 ids per query, tagged so stale replies are dropped.
Finally I'd verify in the field with the web-vitals attribution build, aiming for INP under 200 ms at the 75th percentile, and watch Long Animation Frame reports for regressions.
Question 2
What's the difference between yielding with setTimeout and with scheduler.yield()?
Both end the current task so the browser can handle input and paint. The difference is where your work resumes. With setTimeout(fn, 0) the continuation goes to the back of the task queue, behind anything else queued, and nested timeouts get clamped to 4 ms. With scheduler.yield() the continuation is prioritized ahead of other tasks of the same priority, so the job finishes sooner.
scheduler.yield() is in Chrome 129 and Firefox 142 but not Safari, so production code feature-checks it and falls back to setTimeout. I'd also yield by time, about every 40 ms, rather than after every item.
Question 3
When does a Web Worker not help with a slow interaction?
When the slow part touches the DOM: rendering components, measuring layout, or a huge style recalculation. Workers can't access the DOM, so that work has to be reduced (virtualize, avoid re-renders, batch reads and writes) or chunked on the main thread.
It also doesn't help when the work is tiny, because the message round trip costs more, or when the data has to be copied in on every call: cloning a large object is itself main-thread work. The fix there is to keep the data in the worker and transfer buffers instead of copying.
Question 4
INP is 600 ms but the click handler only takes 30 ms. Where would you look?
The other two parts. If input delay is large, some other task was running when the user clicked: hydration, a third-party script, a timer. LoAF data or a trace shows which script; the fix is to break it up, defer it, or lower its priority.
If presentation delay is large, the rendering after the handler is slow: a huge DOM, a style change on a high-level element, layout thrashing, or a heavy requestAnimationFrame callback. Virtualizing, scoping style changes, and batching layout reads fix those.
Question 5
Should every component be wrapped in React.memo?
No. memo adds a props comparison to every render and only pays off when the component is expensive and its props are actually stable. Wrapping everything without stabilizing props adds cost and saves nothing.
I'd fix structure first: move state down, pass expensive subtrees as children, split contexts. Then memoize the few hot components the React Profiler points to. With the React Compiler enabled, most manual memoization becomes unnecessary because the compiler adds it.
Practice it
These design problems lean on runtime performance.
- Design a tooltipEasy
- Design a countdown timerEasy
- Design a signature padEasy
- Design an image carouselMedium
- Design a throttled progress bar queueMedium
- Design a drag-and-drop reorderable listMedium
- Design a markdown editor with live previewMedium
- Design a color pickerMedium
- Design an avatar image cropperMedium
- Design a podcast player with a synced transcriptMedium
- Design a real-time leaderboardMedium
- Design a live stock price tickerMedium
- Design an LLM chat interface with streaming answersMedium
- Design a plan to make a slow e-commerce site fastMedium
- Design a data tableHard
- Design a virtualized listHard
- Design a calendar week viewHard
- Design a Kanban boardHard
- Design a combobox for 100,000 optionsHard
- Design a rich text editorHard
- Design an embeddable code editorHard
- Design a PDF viewerHard
- Design live comments for a sports streamHard
- Design a multiplayer trivia gameHard
- Design a TikTok-style vertical video feedHard
- Design a seat selection map for eventsHard
- Design Uber's rider web appHard
- Design Google SheetsHard
- Design a collaborative design tool like FigmaHard
- Design a whiteboard like ExcalidrawHard
- Design a code review diff viewerHard
- Design a web-based terminalHard
- Design an analytics dashboardHard
- Design an IoT dashboard for thousands of sensorsHard
- Design a log viewer for millions of linesHard
- Design Google MapsHard
- Design a collaborative pixel canvas like r/placeHard
- Design a web photo editorHard
- Design a browser-based music studioHard
- Design a video call web app like Google MeetHard
- Design a Twitch-style live stream pageHard
- Design a browser video editorHard
- Design a stock trading appHard
- Design a 3D product configuratorHard
References
- Interaction to Next Paint (INP) Definition, thresholds, and how the worst interaction is chosen.
- Optimize long tasks Chunking, yielding, and scheduler.yield() in detail.
- Optimize Interaction to Next Paint Fixes for each of the three parts of INP.
- Long Animation Frames API Fields, attribution, and field collection.
- Scheduler: yield() method API reference and browser support.
- Using Web Workers Workers, messages, and transferable objects.
- Comlink RPC-style wrapper around postMessage.
- React Compiler Automatic memoization and its requirements.
Read next
- Inside the browserHow a modern browser splits its work across processes and threads, why your JavaScript shares one main thread with layout, paint, and input, and how the event loop, workers, and OffscreenCanvas decide whether a page stays responsive.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Layout, paint, and compositingWhat the browser redoes when something on the page changes after the first render, why moving a box with
transformis cheap while changing itswidthis not, and how to keep scrolling and animation inside a 16 ms frame. - List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Local vs global stateWhere each piece of state lives decides what re-renders when it changes: keep it next to its readers, lift it only as far as needed, and use context or a store with selectors only for state that many distant parts share.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.