skip to content

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%

answer

  1. guessing versus asking
  2. yield when someone actually wants the thread
  3. input only — not rendering or timers
  4. continuous events are opt-in
  5. the check is not itself a yield

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.

solid answer

~50 s

Fixed time-slicing is a guess — you yield every 50 ms whether or not anyone wants the thread, so you pay scheduling overhead when the page is idle and can still make a user wait up to a full slice. `navigator.scheduling.isInputPending()` lets you invert that: run flat out while the answer is false, give up the thread the moment it turns true. The catches are real, though. It is Chromium-only, so you need a feature-detected fallback. It reports *input* only — a loop that never sees input can still starve rendering, animations and timers for seconds. Continuous events like scroll and pointermove are excluded unless you pass `{ includeContinuous: true }`. And the check itself is not a yield: you still have to return to the browser once it says true. In practice I'd combine it with a time cap so rendering is never starved.

code

javascript · 22 lines
javascript
function yieldToMain() {
  return new Promise((resolve) => setTimeout(resolve, 0));
}

async function runBatch(items, work) {
  const canAsk =
    'scheduling' in navigator && 'isInputPending' in navigator.scheduling;
  let sliceStart = performance.now();

  for (const item of items) {
    work(item);

    const inputWaiting =
      canAsk && navigator.scheduling.isInputPending({ includeContinuous: true });

    // Yield on input, or on a time cap so rendering is never starved.
    if (inputWaiting || performance.now() - sliceStart > 50) {
      await yieldToMain();
      sliceStart = performance.now();
    }
  }
}

go deeper

for a junior

Know that long main-thread jobs need to hand the thread back periodically, and that this API is one way to decide when — you are not expected to have used it.

for a middle

Explain the difference between yielding on a fixed time budget and yielding on an interrupt signal, and note that the API reports input only and needs feature detection.

for a senior

Show that you would pair the check with a time cap so rendering is not starved on an idle page, keep the unit of work small enough that the check interval is short, and degrade cleanly where the API is absent.

for a principal

Take a position on whether this complexity is warranted at all: most responsiveness wins come from doing less work or yielding in the right place, and a browser-specific optimisation needs a measured payoff to justify the maintenance cost.

## The problem with time-slicing alone The standard way to keep a long job from freezing a page is to slice it: do work for N milliseconds, hand the thread back, resume. It works, but the slice length is a pure guess with costs on both sides. - **Too long** and a user who clicks mid-slice waits up to the full slice before their event is even dispatched. That wait is input delay, and it counts against the interaction. - **Too short** and you pay scheduling overhead constantly — every handback costs a trip through the browser's task machinery — for the overwhelming majority of slices where nobody wanted the thread at all. You are optimising against an unknown: *is anybody waiting right now?* ## What the API answers `navigator.scheduling.isInputPending()` answers exactly that. It returns `true` if input events are queued and waiting to be dispatched. That converts a periodic guess into an interrupt check: run at full speed while nothing is pending, release the thread the moment something is. On an idle page the job finishes faster than time-sliced code; on an active page the user's click is serviced within one item's worth of work. It takes an options argument: `isInputPending({ includeContinuous: true })`. By default **continuous** events — the high-frequency stream like `mousemove`, `pointermove` and scrolling — are excluded, on the reasoning that a job which yielded to every one of them would never make progress while the pointer is moving. If your page does meaningful work in response to those, opt in; if it does not, leaving them out is often the right call. ```javascript if (navigator.scheduling?.isInputPending()) { await yieldToMain(); } ``` ## The four things that go wrong **1. It is not universally available.** This is a Chromium API (Chrome 87 and later); Firefox and Safari do not implement it. Any code that assumes it exists either throws or, worse, silently never yields on the browsers where it is missing. Feature-detect and fall back to time-slicing. **2. It only knows about input.** This is the deepest trap. A job on a page nobody is touching gets `false` forever and will happily run for ten seconds straight — starving rendering, animations, timers, and network callbacks. A spinner freezes, a CSS-driven animation stalls, a queued fetch handler waits. "No input pending" is not "nothing else needs the thread." This is why a time cap belongs alongside the check, not instead of it. **3. Asking is not yielding.** The call is a query. Getting `true` back changes nothing by itself; you must actually return control. Code that logs the result and keeps looping has all of the overhead and none of the benefit. **4. Granularity still matters.** The check happens between items. If one item takes 200 ms, input can queue right after a check and still wait 200 ms for the next one. The API bounds your responsiveness by the size of your unit of work, so units have to be small enough that a check-to-check interval is short. ## The realistic shape A production version combines both signals: - Yield when input is pending, so an interaction is never stuck behind a slice. - Yield on a time cap regardless, so rendering and other tasks get their turn on an idle page. - Feature-detect, and degrade to the time cap alone where the API is absent. - Keep the unit of work small enough that the interval between checks is short. That gives you throughput when the page is quiet and responsiveness the moment it is not. ## When it is worth reaching for Honestly: not often. Most responsiveness problems are solved by doing less work or by putting the yield in the right place inside an interaction, and a plain time cap is good enough for the rest. The interrupt check earns its keep for genuinely long, resumable, main-thread-bound batch work — parsing and indexing a large payload, hydrating a big list — where the throughput lost to over-yielding is measurable. Treat it as a refinement on a working time-sliced loop, not as the first tool you pick up.

  • Why does the API exclude continuous events by default?
    Because a loop that yielded to every pointermove or scroll event would never make progress while the user's finger or mouse is moving — the queue effectively never empties. Excluding them keeps the check meaningful for discrete input like clicks and keypresses. If your page does real work on those continuous events, pass `{ includeContinuous: true }` and accept the slower batch throughput.
  • Your batch runs on a page the user is not touching. What can still go wrong?
    The check returns false the whole time, so the loop never yields and monopolises the thread. Rendering, animations, timers and network callbacks all stall — the page looks frozen even though no input is queued. That is why the input check needs a time cap beside it: input is only one of the things that wants the main thread.
  • How would you write this so it behaves sensibly in Safari?
    Feature-detect `navigator.scheduling?.isInputPending` and treat its absence as "always yield on the time cap." The loop keeps a slice timer either way and only consults the API as an early-exit signal when it exists. That way Chromium gets the extra throughput and everyone else gets a correct, if slightly blunter, time-sliced loop.

saying these in an interview costs you the question

  • Treats it as available in every browser
  • Assumes a false return means it is safe to keep running indefinitely
  • Expects scroll or pointermove to be reported by default
  • Thinks calling it yields to the browser by itself
  • Believes it guarantees the pending frame will paint

context