gap·map

Rendering performance

[ middle depth ]

Why one long task delays every click

Everything user-facing competes for the same main thread: your JS, event handlers, React renders, style, layout, paint. Tasks run to completion, with no preemption. Responsiveness is therefore a queueing problem. A click during a 900ms task doesn't get "handled slowly"; it gets handled 900ms late, in full, after the task ends. The event-loop topic owns the queue mechanics (microtasks drain before paint, etc.). This topic covers what that queue does to interactions.

A long task is anything over 50ms. The number comes from the response budget: to answer an interaction within ~100ms, the browser has to be able to start within ~50ms, so every task longer than that is a bet that no one interacts while it runs.

What INP measures

INP (Interaction to Next Paint) measures clicks, taps, and keypresses (not scroll, not hover) from hardware event to the next painted frame. Good ≤ 200ms, poor > 500ms. It reports the worst interaction of the visit (minus one outlier per 50), at p75 across users. One frozen keystroke sets your score.

Each interaction decomposes into three phases, and the fix depends on which one dominates:

  1. Input delay. The event sits in the queue because something else is running. Cause: other long tasks, yours or a third-party's. Fix: break them up.
  2. Processing duration. All handlers for the event run. Fix: do less in the handler; defer what doesn't need to happen before the next paint.
  3. Presentation delay. The rendering work your handler queued: style, layout, paint of whatever you changed. A 5,000-node DOM update lives here. Fix: change less DOM.

❌ "INP is FID renamed". FID was the input delay of the first interaction only. A page could greet click one in 30ms and freeze on keystroke twenty; FID never saw it, and INP is designed to.

How to break up a long task

Splitting a long task means inserting points where the browser can run input and paint between your chunks:

async function processAll(items) {
  for (const batch of chunks(items, 100)) {
    processBatch(batch);
    await scheduler.yield(); // continuation resumes at the FRONT of the queue
  }
}
  • setTimeout(0) is the original. It works, but your continuation goes to the back of the queue (anything else, including third-party tasks, cuts in), and nested timers clamp to ~4ms.
  • requestIdleCallback is for genuinely optional work: it runs when the browser is idle and hands you a timeRemaining() deadline. Pass a timeout or it may never run, there's no Safari support, and don't mutate the DOM in it.
  • scheduler.yield() is the purpose-built one: it yields, then resumes ahead of other same-priority tasks, so splitting your work doesn't mean losing your place. Its sibling scheduler.postTask() schedules new work with explicit priority.

await Promise.resolve() between chunks yields nothing. It's a microtask, and microtasks drain before the browser gets a rendering opportunity. If your "chunked" loop still shows one solid block in the trace, this is usually why.

startTransition and useDeferredValue

A React render costs roughly what it creates and reconciles. Each keystroke that re-renders a 5,000-row list is a single long task; the per-row work is trivial, and the count is the problem. Two APIs reshape the schedule:

  • startTransition(fn) marks state updates in fn as non-urgent: React renders them in the background and abandons the render if a newer update arrives.
  • useDeferredValue(value) applies the same idea to a value you don't own the setter of: the UI renders with the old value first, then re-renders with the new one at low priority.

Interviewers probe the semantics here, because these APIs do not make the render faster. The same work happens, interruptibly, off the urgent path. The controlled input's own value must stay urgent (a laggy value echo is worse than a laggy list), and the expensive child must be wrapped in memo, or the urgent parent render drags it along and nothing was deferred. Versus debouncing: a debounce picks a fixed delay and still runs the full-cost render at the end; a deferred value lags exactly as much as the device is slow and cancels stale work.

How layout thrashing shows up in a trace

Many small Layout blocks inside one handler, with forced-reflow warnings, mean interleaved geometry reads and style writes are inflating processing duration: batch reads before writes. The mechanism (writes invalidate, reads flush) belongs to critical-rendering-path. Here it's one diagnosis among several. Check whether your 900ms is render count, forced layout, or plain compute before choosing a fix.

When to virtualize a long list

Windowing renders only the visible rows plus an overscan buffer, absolutely positioned inside a spacer element whose height equals the total list size. The scrollbar stays honest while the DOM holds ~30 nodes instead of 5,000. Scroll position determines the window; variable-height rows need measurement after render (which is why libraries expose measureElement-style hooks and estimated sizes).

It's a trade, not a free win. Off-screen rows don't exist, so screen readers can't reach them, find-in-page finds nothing, and scroll restoration needs the library's cooperation. Fast flick-scrolling can outrun rendering and flash blanks (overscan is the tuning knob). Take virtualization when DOM size itself is the problem. When it isn't, content-visibility: auto (see critical-rendering-path) keeps content real while skipping its rendering.

[ senior depth ]

How to schedule main-thread work by priority

Applied ad hoc, the middle-level toolbox (yield, defer, transition) produces whack-a-mole. A policy works better: classify every piece of main-thread work by priority, keep each task under ~50ms by construction, and make stale work abortable.

const controller = new TaskController({ priority: "background" });
scheduler.postTask(rebuildSearchIndex, { signal: controller.signal });
// user typed again — the old index build is now garbage:
controller.abort();

scheduler.postTask gives you three lanes (user-blocking, user-visible as the default, background), and tasks run in priority order, not arrival order. TaskController lets you abort or reprioritize queued work. React's concurrent renderer implements the same idea internally: interruptible units of work, where newer input invalidates older output. Whether you get it from React or build it with the scheduler API, the principle is the same: never finish work whose result no one wants anymore.

Workers are the only true parallelism, and their rule is unforgiving: pure compute can move (parsing, search, diffing, image processing); the DOM stays on the main thread. Before moving anything, price the boundary. postMessage structured-clones the payload, and shipping 50MB of rows across it per keystroke can cost more than the compute you saved (transferables, or moving the data's home into the worker, fix that). ❌ Interviewers treat a worker prescribed for a render-bound problem as a red flag: if the trace says 890 of 900ms is React commit and layout, a worker relocates the innocent 10ms.

Frame budgets at 60Hz and 120Hz

At 60Hz a frame is 16.7ms, of which maybe 10ms is yours after browser overhead; 120Hz displays halve it. Consequences: a task under 50ms is fine for responsiveness yet still drops frames during animation, because the two modes have different budgets. And presentation delay is where big commits hide. React's render phase is interruptible; the commit runs to completion. A transition can't save you from a 300ms commit of 5,000 DOM nodes; only committing less DOM can (virtualization, content-visibility, smaller invalidation scopes).

How to measure INP in the field

A dev-machine trace is a lie about your p75 user. Field diagnosis is its own skill:

  • The web-vitals library's attribution build tells you, per slow interaction: which element, which phase (input delay / processing / presentation), which script. That decides the fix. Input delay indicts other tasks (often third-party), processing indicts your handlers, presentation indicts DOM size.
  • The Long Animation Frames (LoAF) API goes deeper than the old Long Tasks API: script attribution with source location, plus style and layout cost per frame. Enough to name the culprit in production.
  • Then reproduce in the lab with CPU throttling, fix, and confirm the field p75 moved. Optimizing an interaction your RUM data doesn't complain about is a hobby.

Structural fixes beat micro-memoization

memo/useMemo/useCallback sprinkled everywhere changes constants, adds comparison overhead, and shatters on the first inline object, while the row count stays 5,000. Prefer, in order:

  1. Do less. Virtualize, paginate, content-visibility: change O(N) to O(viewport).
  2. Restructure. Colocate state so updates render small subtrees; pass expensive children through children so the stateful wrapper doesn't recreate them (react-rendering owns the details).
  3. Reschedule. Transitions, deferred values, postTask: same work, off the urgent path.
  4. Memoize. Last, surgically, where a profile shows a hot comparison-stable subtree.

Reaching for #4 first is the most common miss; without a profile showing a hot, comparison-stable subtree, memoization rarely pays for its own overhead.

When virtualization is the wrong tool

Virtualization is for when the DOM itself is the bottleneck: memory, style recalc, commit size, hydration. Wrong tool when:

  • The list is small or rows are cheap. Windowing bookkeeping (scroll math, measurement, absolute positioning) costs more than it saves; a 100-row table needs nothing.
  • Content must be findable or accessible. Find-in-page, Ctrl+F muscle memory, screen-reader browsing, SEO on server-rendered lists: off-screen rows don't exist. content-visibility: auto keeps the DOM real (in the a11y tree and find-in-page) while skipping off-screen rendering, so try it first.
  • Row heights are wildly dynamic and interactive (expanding threads, embeds). Measurement churn causes scroll jumping, and the engineering cost of getting it right is real.
  • The honest fix is upstream. Nobody scrolls 5,000 rows; pagination, server-side search, or a better default filter removes the problem instead of rendering it efficiently.

A slow interaction is a budget problem: 200ms, three phases. Find which phase you're over in, then pick the cheapest structural fix that gets you under. Sometimes that fix is "don't virtualize".

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more Web Performance