cf.completefrontend
My progress
FUNDAMENTALS · MODULE 8

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.

A timeline from 0 to 600 milliseconds. The main thread runs one 420 millisecond task that filters 8,000 rows and renders the table for the key r. Keys e and v arrive at 110 and 230 milliseconds and wait in the queue. The first paint happens only at 420 milliseconds, then the task for e starts.0 ms200 ms400 ms600 msmainkey r: filter 8,000 rows, renderkey e startsfirst paintkey rkey ekey ve and v wait in the queue;nothing paints until 420 ms
Keys that arrive during a long task can't even start their handlers. The user sees nothing for 420 ms, then letters arrive in a burst.

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.

Two bars drawn to scale. At 60 hertz a frame has 16.7 milliseconds: about 10 for your JavaScript, 4 for style and layout, 2 for paint, and a little idle time. At 120 hertz a frame has 8.3 milliseconds: about 4 for JavaScript and about 4 for style, layout, and paint. A 40 millisecond task misses 2 frames at 60 hertz and 4 at 120 hertz.16.7 ms budget60 Hzyour JavaScript ~10 msstyle, layoutpaint8.3 ms budget120 HzJS ~4 msrender ~4a 40 ms task misses 2 frames at 60 Hz, 4 at 120 Hz
The budget is small and shrinks on faster screens. Your JavaScript shares it with the browser's own rendering work.

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.

The handful of numbers this whole topic rests on.
NumberWhat it meansWhere you meet it
8.3 msFrame budget at 120 HzSmooth scrolling and drags on modern phones
16.7 msFrame budget at 60 HzAnimations, requestAnimationFrame
50 msA task longer than this is a long taskLong Tasks API, DevTools red corners, Total Blocking Time
100 msResponse feels instantRAIL model, the goal for any input
200 msGood INP at the 75th percentileCore Web Vitals
500 msAbove this, INP is poorCore 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.

  1. 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.
  2. Processing time: the time your event handlers take to run. For a key press that's keydown, beforeinput, input, and keyup handlers; for a tap it's pointerdown, pointerup, and click. In a framework, this includes the state update and the render it triggers if they run synchronously.
  3. Presentation delay: from the end of the handlers until the frame is painted. The browser has to run requestAnimationFrame callbacks, recalculate style, lay out, and paint. A huge DOM or a style change that touches every element makes this part large.
A sequence diagram with four columns: user, task queue, main thread, and screen. The user presses v. The key waits in the task queue for 40 milliseconds of input delay while another task finishes. The main thread then runs the handlers for 320 milliseconds of processing, then style, layout, and paint for 60 milliseconds of presentation delay, and sends the next frame to the screen. INP is 40 plus 320 plus 60, which is 420 milliseconds.usertask queuemain threadscreenpress vstarts after 40 msprocessing320 mspresentation60 msnext paintINP = 40 + 320 + 60 = 420 ms
Every interaction has the same three parts. The fix depends on which one is big, so measure the split before changing code.

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.

ts
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 });
Report slow frames with the script that cost the most. In production, sample this: it can fire often on slow devices.
One long animation frame entry, line by line. duration 312: from frame start to paint. blockingDuration 262: time input could not run. renderStart 284 and styleAndLayoutStart 290: when rendering and layout began. Its worst script has invoker BUTTON#export.onclick, sourceURL /js/app.js, and forcedStyleAndLayoutDuration 74, meaning 74 milliseconds of forced layout.a long-animation-frame entryduration: 312frame start to paintblockingDuration: 262time input couldn't runrenderStart: 284scripts done, render beginsstyleAndLayoutStart: 290style and layout begininvoker: "BUTTON#export.onclick"what started the scriptsourceURL: "/js/app.js"which file to blameforcedStyleAndLayoutDuration: 74layout thrashing inside it
The top four fields say how bad the frame was and how much went to rendering. The script fields, from 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.

ToolWhereTells youMisses
Long Tasks APIField, all ChromiumThat a task over 50 ms ran, and how longWhich script; rendering cost
Long Animation FramesField, Chrome 123+Slow frames, scripts, forced layout, render timeNon-Chromium browsers
INP with attributionFieldThe worst interaction and its three partsWork that isn't tied to an interaction
Total Blocking TimeLab (Lighthouse)Main-thread blocking during loadAnything after load, real input
DevTools PerformanceLab, your machineEvery task, call stack, and frameWhat 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.

Two rows on the same time axis. Before: one 300 millisecond task; a tap at 60 milliseconds waits until the task ends, and the next paint is at 316 milliseconds. After: the same work in 40 millisecond chunks with short yields between them; the tap at 60 milliseconds runs after the second chunk, and the next paint is at 98 milliseconds. The rest of the work finishes at 330 milliseconds.beforeone 300 ms tasktapnext paint at 316 mssame work in 40 ms chunks, yielding between themaftertapnext paint at 98 ms
Same work, same thread. Yielding lets the tap slip in between chunks, so its paint comes 218 ms sooner. The cost: the whole job ends a little later.

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.

ts
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;
}
Chunk by time, yield about every 40 ms, and check an 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), or background. 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 to setTimeout.
  • 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). Use scheduler.yield() instead.

Decision Which tool should split a 600 ms job on the main thread?

setTimeout chunks

Do 40 ms of work, then setTimeout(next, 0)

Pros

  • Works everywhere
  • Simple to reason about

Cons

  • Resumes at the back of the queue
  • 4 ms clamp on deep nesting

scheduler.yield() with fallback

await yieldToMain() every 40 ms inside the loop

Pros

  • Resumes ahead of other tasks
  • Reads like normal async code

Cons

  • No Safari support yet, so the fallback runs there
  • Still costs main-thread time

scheduler.postTask

Queue each chunk with priority: "background"

Pros

  • Explicit priorities
  • Can be aborted with a TaskController

Cons

  • Same support gap
  • More code than a yield loop

Move it to a Web Worker

Send the data to a worker, get the result back

Pros

  • Main thread does almost nothing
  • No chunking logic

Cons

  • No DOM access
  • Copying data in and out costs time

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.

Two zones side by side. The main thread holds SearchInput, which paints each key, and ResultsTable, which renders 50 rows. The worker thread holds a Comlink-exposed search function and an index of 50,000 items. The page calls search with the query; the worker returns 50 ids. The worker fetches products.json itself from the network. A note says postMessage copies data, so transfer ArrayBuffers instead.main threadSearchInputpaints each keyResultsTablerenders 50 rowsworker threadsearch(query)Comlink.exposeindex50,000 items"rev"50 idsproducts.jsonfetched in the workerpostMessage copies data;transfer ArrayBuffers instead
The page sends a few bytes of query and gets back 50 ids. The big dataset never crosses the boundary: the worker fetched it itself. Every 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 fetch instead 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 ArrayBuffer in the transfer list: worker.postMessage(buf, [buf]). Ownership moves to the worker in constant time and the page's copy becomes empty. ImageBitmap, MessagePort, and OffscreenCanvas transfer too.
  • Return the minimum. Send back the 50 matching ids, not the 50,000 rows with a flag on each.

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.

ts
// 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
The data is fetched and indexed in the worker. Only the query and 50 results cross the boundary.

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.hardwareConcurrency is 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.

A component tree. Page holds the query state and has three children: Search, Chart, and Table. Table has 8,000 Row children. On a keystroke, Page, Search, Table, and every Row re-render. Chart is wrapped in React.memo with unchanged props, so it is skipped.Pagestate: querySearchneeds the new textChartmemo, same propsTableRowRowRownew rowsx 8,000 rowsred: re-rendersgreen: skipped
Red nodes re-render on every keystroke. The state's owner decides the blast radius: here, 8,000 rows. Memo saved the chart, but the real fix is fewer rows in the DOM and state placed where it's used.

Structural fixes first

  • Move state down. Keep the search text inside the SearchBox, not in Dashboard. Only the box re-renders as the user types.
  • Lift content up. A component's children were created by its parent, so they don't re-render when the component's own state changes. A Collapsible can 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's memo.

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.

Note text state lives in
Table in React.memo
onSelect passed as
React Compiler
Component tree: Page renders Header, NoteBox, and Table, and Table renders 500 rows. On one keystroke, Page renders, Header renders, NoteBox renders, and Table with its rows render. 504 components render, about 92.3 milliseconds per keystroke on a mid-range phone.PageHeaderNoteBoxTableRow x 500red: renders on each keygreen: skipped504 components render per keystrokeabout 92.3 ms of rendering per key
State in Page re-renders everything under it, including all 500 rows, on every keystroke.

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

  1. Use an incognito window, so extensions don't add tasks to the trace.
  2. 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.
  3. 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.
  4. Press Record, do the one slow interaction, and stop. Short traces are easier to read.
A sketch of the DevTools Performance panel. The Interactions track shows a keydown of 420 milliseconds. The Main track shows one long task, striped red beyond 50 milliseconds, followed by purple style and layout and green paint. Under the task, the flame chart reads input handler, React render, then filterAndSort and Row times 8,000, then sort. The Bottom-up tab lists Row render at 230 milliseconds and filterAndSort at 90 milliseconds. A note lists the reading order: Interactions, Main, Bottom-up.PerformanceInteractionskeydown 420 msMainlayout, paintinput handlerReact renderfilterAndSortRow x 8,000sortBottom-upRow render 230 msfilterAndSort 90 ms1. Interactions: which part?2. Main: widest bar of yours3. Bottom-up: top self time
Read top to bottom: which interaction and which INP part, then the widest bar of your own code in the flame chart, then Bottom-up to total the self time per function.

What to look at, in order

  1. 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.
  2. 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.
  3. 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 offsetHeight after writing styles, in a loop.
  4. The Bottom-up tab. It totals self time per function across the selection. If Array.prototype.sort or a date-formatting function sits at the top, you've found your target even if it's called from many places.
  5. 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.

tsx
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.

Four stacked bars, one per step of the dashboard fix, split into input delay, processing, and presentation delay. Before: 40, 320, and 60, total 420 milliseconds. With useDeferredValue: 40, 15, and 60, total 115. Plus virtualization: 40, 15, and 12, total 67. Plus splitting the analytics work into short background-priority tasks: 5, 15, and 12, total 32. A dashed line marks the 200 millisecond good threshold.input delayprocessingpresentationbefore420 msuseDeferredValue115 ms+ virtualize67 ms+ background tasks32 ms200 ms: good INP
Each step attacks the biggest remaining part. Deferring the table fixed processing; virtualizing fixed presentation; splitting the analytics work into short, low-priority tasks removed most of the input 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.

Work in the handler
Run it as
400 milliseconds of work run as one task. Click 1 at 0 milliseconds has an INP of 411 milliseconds. Click 2 at 60 milliseconds has an INP of 362 milliseconds. 24 of 25 frames are dropped, and the results are shown at 411 milliseconds.handlerwork3rd partypaintframesmainworkerclick 1click 2click 1 INP: 411 msclick 2 INP: 362 msframes dropped by the work: 24 of 25results shown at 411 msmodel: 40 ms chunks, a 20 ms script every 100 ms
The handler and the work are one task. Click 1 can't paint until all the work is done, click 2 waits behind it, and every frame in between is lost.

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 sourceURL points 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 AbortSignal or a version number.
  • Memoization that never hits. memo everywhere, 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.
A flowchart. Start at a slow interaction, then ask which INP part is biggest. Input delay: split, defer, or lower the priority of the task that was running. Presentation delay: shrink the DOM, scope style changes, and stop layout thrashing. Processing: ask whether the work needs the DOM. Yes: do less and yield, with memo, virtualization, and chunks. No: move it to a Web Worker that keeps the data.slow interactionbiggest INP part?input delaysplit or defer that taskprocessingdoes it need the DOM?presentationsmaller DOM, less layoutdo less, yieldmemo, virtualize, chunkWeb Workerkeep the data thereyesnothen measure again: same trace, same throttling
Two questions pick the fix: which part of INP is biggest, and, for processing, whether the work needs the DOM. Then measure again with the same trace settings.

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 useTransition and useDeferredValue work.
  • 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?

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 to setTimeout. 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.

Question 2

What's the difference between yielding with setTimeout and with scheduler.yield()?

Question 3

When does a Web Worker not help with a slow interaction?

Question 4

INP is 600 ms but the click handler only takes 30 ms. Where would you look?

Question 5

Should every component be wrapped in React.memo?

Practice it

These design problems lean on runtime performance.

References

Read next