skip to content

Yielding and Scheduling Work

Yielding to the browser between chunks of work is the single most effective INP fix, but only if you yield at the right moment. This node is about that judgement call and how you measure the result.

on this pageshow

questions

4

On a web page, a button's click handler marks the row as selected in the DOM and then spends about 300 ms recalculating a summary, all in one synchronous block, and Interaction to Next Paint (INP) for that button is poor. Where in the handler do you introduce a yield back to the browser, and why does the placement matter more than how much work the handler does?

level: middleimportance: must knowfreq 50%

answer

  1. where you yield, not how much
  2. the frame is what gets timed
  3. paint the feedback before the heavy part
  4. a yield after the work changes nothing
  5. cancel superseded continuations on re-click

basics

~20 s

Yield right after the DOM update that shows feedback and before the expensive recalculation. The interaction is timed until the next paint, so painting the feedback first closes the measured window while the heavy work runs after it.

solid answer

~50 s

I'd split the handler at the feedback boundary. First do the smallest DOM update that tells the user the click registered — add the selected class, disable the button, show a spinner — then hand control back to the browser, and only then run the 300 ms recalculation. An interaction is measured up to the next frame the browser paints after the handlers finish, so if that paint has to wait for the recalculation, the full 300 ms lands in the score; if the feedback paints first, the measured interaction ends there and the expensive part continues as separate work afterwards. Placement beats volume because a yield at the *end* of the handler changes nothing — the frame was already delayed. I'd also make the deferred work cancellable so a second click doesn't stack another recalculation behind the first.

go deeper

for a junior

Be ready to say the user must see something happen immediately, so the visible DOM update goes first and the slow work goes afterwards. Naming that ordering clearly is enough at this level.

for a middle

Explain that the interaction is measured until the browser paints the next frame, so a yield placed between the feedback update and the heavy work moves that work out of the measured window entirely.

for a senior

Show production judgment: decide which work truly must run inside the handler, cancel or coalesce continuations when the user interacts again, and confirm the change in real-user data rather than trusting a local trace.

for a principal

Own the tradeoff of making this a team norm. Say where responsiveness budgets sit, when deferring merely relocates jank into the next interaction's input delay, and when the honest answer is doing less work rather than rescheduling it.

## What is actually being timed An interaction is not scored on how long your JavaScript ran. It is scored from the moment the user's input arrives to the moment the browser paints the next frame after the event handlers have finished. That span is conventionally read in three parts: **input delay** (the main thread was busy, so the event waited), **processing** (your handlers running), and **presentation delay** (style, layout and paint of the resulting frame). Any main-thread work sitting between the input and that paint is inside the number, whatever it is doing and whoever wrote it. This is why "the handler is slow" and "the interaction scores badly" are the same sentence only by accident. A handler can do a great deal of work and still score well, provided the work does not stand between the input and the frame the user is waiting to see. ## Why the split goes at the feedback boundary Inside almost every heavy handler there are two different kinds of work: 1. **The visible acknowledgement** — the class change, the checkbox state, the spinner, the disabled button. This is what the user is looking for. It is usually a handful of DOM writes and costs almost nothing. 2. **The consequence** — recomputing a summary, re-sorting a list, serialising state, rebuilding a chart's data. This is where the 300 ms lives, and the user does not need to see it in the same frame. Put the yield between them. The acknowledgement runs, control goes back to the browser, the browser paints, and the measured interaction ends there. The consequence then runs as separate work outside the interaction window. ```javascript button.addEventListener('click', async () => { row.classList.add('selected'); // 1. acknowledgement await yieldToMain(); // 2. let the browser paint it summaryEl.textContent = recalculate(); // 3. the consequence }); ``` The common mistake is to yield at the wrong end — to do the recalculation, then yield, then update the DOM. That version is exactly as slow as the original: the paint the user is waiting for is still behind 300 ms of script. ## What counts as a yield A yield here means returning to the browser in a way that lets it run its rendering steps before your continuation resumes. Not every `await` does that — resuming from an already-settled promise continues in the same turn, before the browser gets a chance to render, so it silently produces the "I yielded and nothing changed" result. If you are unsure, verify empirically: record the interaction and check that a paint sits between the two script chunks. ## Make the continuation cancellable Splitting a handler creates a queue where there wasn't one. Click five times quickly and you now have five pending recalculations, each landing after its own feedback paint, all competing for the thread — and the later ones are pure waste because only the last result is wanted. Carry a token or an `AbortController` and drop superseded work: ```javascript let pending; async function onSelect(id) { pending?.abort(); const controller = new AbortController(); pending = controller; markSelected(id); await yieldToMain(); if (controller.signal.aborted) return; render(recalculate(id)); } ``` Without this, deferring work often just relocates the jank: the continuation from click *n* is on the thread when click *n+1* arrives, and it shows up as input delay on the next interaction instead of processing time on this one. ## When the split does not help Three cases, worth naming in an interview because they show you are not applying the trick blindly: - **The acknowledgement itself is expensive.** If the "cheap" update replaces ten thousand rows, the presentation phase dominates and no amount of rescheduling shortens the frame. The fix is rendering less. - **There is nothing to acknowledge.** Some interactions genuinely have no meaningful intermediate state to paint; then the honest answer is to make the work cheaper, do less of it, or move it off the main thread. - **The remaining chunks are still huge.** One yield turns a 300 ms task into a short one plus a 295 ms one. That second chunk still blocks whatever comes next, so long-running continuations need slicing too. ## The habit to demonstrate Ask of every line in a handler: *does the next frame need this?* If not, it goes after the yield. That single question, applied consistently, moves interaction scores more reliably than micro-optimising the work itself, because it changes what the user waits for rather than how fast the machine is.

  • How would you confirm the browser actually painted before your continuation ran, rather than assuming it?
    Don't assume — verify. Record the interaction in a performance trace and check that a paint sits between the two script chunks, or look at the interaction's processing versus presentation phases in field data. If the continuation is scheduled in a way that resumes before rendering, the paint slips behind it and the score doesn't move even though the code looks split.
  • The user clicks the same button five times in a second. What does your split handler do now?
    Five continuations queue up, so each click gets fast feedback but the thread is saturated by work whose results are already stale. I'd keep a token or AbortController, abort the previous continuation when a new click arrives, and coalesce to the latest request — otherwise deferring the work has just converted processing time into input delay for the following interactions.
  • When does moving work out of the handler fail to improve the interaction at all?
    When the expensive part is the frame itself. If the acknowledgement replaces a huge subtree, the presentation phase dominates and scheduling changes nothing — the browser still has to style, lay out and paint it. Then the answer is rendering less: smaller updates, fewer nodes touched, or narrower changes. Yielding relocates work; it never makes a frame cheaper.

A barista who takes your order, calls it out, and then makes the drink feels fast; one who silently makes the whole drink before acknowledging you feels slow — the total work is identical.

saying these in an interview costs you the question

  • Claims any yield helps regardless of where it sits
  • Yields only after the expensive work has already run
  • Thinks awaiting an already-resolved promise hands the frame back
  • Assumes reducing total work is the only available lever
  • Queues a new continuation per click without cancelling the old

context

open as a page

A Save button's click handler on a web page first serializes the whole draft into localStorage, then builds an analytics payload, and only after that updates the DOM to show the "Saved" state. Users say the button feels laggy. What would you change, and why does the order matter?

level: juniorimportance: should knowfreq 40%

basics

~20 s

Show the "Saved" state first, then defer the localStorage write and the analytics payload until after the browser has painted. Only the feedback has to be in that frame; the bookkeeping is what the user is currently waiting behind.

open as a page

A team splits a heavy click handler so it paints feedback and then yields before the expensive work, and local traces show much shorter tasks — yet real-user Interaction to Next Paint for that control has not improved, and a few interactions look worse than before. What would you investigate, and in what order?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Usually the deferred work did not disappear — it now runs while the user's next input arrives and shows up as input delay on the following interaction. Check the interaction's phase breakdown before concluding the split failed.

open as a page

In Chromium, `navigator.scheduling.isInputPending()` reports whether input events are waiting to be processed. A long batch job on the main thread calls it after each item and keeps going while it returns false. What does that buy compared with yielding on a fixed time budget, and what are the risks of relying on it?

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

It turns blind time-slicing into an interrupt check: the job keeps the thread while nobody is waiting and releases it the instant input queues. Risks are that it is Chromium-only, blind to rendering and timers, and excludes continuous events by default.

open as a page