Analysis

Deeper treatment of the decisions this module surfaces. Numbers cited are from measured-results.md unless stated.

Yield primitives compared

PrimitiveCreates a rendering opportunity?Priority on resumeCostUse when
await Promise.resolve() / queueMicrotaskNon/a~freebatching state; never for chunking
setTimeout(fn, 0)Yesback of the queue; clamped (≥ 1 ms, 4 ms when nested ≥ 5 deep)high latencylegacy fallback only
MessageChannel.postMessageYesordinary task, no clampinglowthe reliable hand-rolled yield; what React's scheduler uses
scheduler.postTask({priority})Yesexplicit user-blocking / user-visible / background, AbortSignallowgenuinely competing work classes
scheduler.yield()Yescontinuation priority — ahead of new low-priority work, behind inputlow per call, but resumes behind renderingmid-task yielding where responsiveness dominates

The setTimeout clamping detail matters: nested timers beyond depth 5 are clamped to 4 ms, so a setTimeout-based chunker silently caps at ~250 slices per second regardless of slice size. That alone makes it unsuitable for time-sliced work, before considering queue position.

Why isInputPending() is a trap

isInputPending() yields only when input is actually waiting, which maximises throughput. But rendering is not input. A loop that yields only for pending input can run for seconds without letting a frame through — responsive to clicks, visually frozen. Prefer a time-slice budget (yield every ~5 ms of work) which is insensitive to whether anyone happens to be clicking.

The throughput cost of responsiveness

Measured, 200k rows at 6× throttle: s3 (MessageChannel) and s4 (scheduler.yield) produce identical INP (32 ms, zero long tasks). Their filter wall-clock differs by 1.5–3.5× across runs, s4 always slower — its continuations are scheduled behind rendering by design.

This produces a predictable and legitimate conflict. An engineer will show a benchmark where yielding is 30 % slower and propose reverting. They are not wrong about their number. The resolution is to establish which number represents the user:

  • A human is waiting for incremental feedback → responsiveness wins. Total time is invisible; unresponsiveness is not.
  • A human is waiting for the completed result and nothing else → total time is the experience. Yielding makes it worse. Show a determinate progress indicator and do not yield.
  • No human is waiting (background sync, prefetch, export) → total time wins outright, and you should be yielding only enough to not block interactions that might arrive.

The failure mode to avoid is deciding this by ideology in either direction. "Always yield" and "never yield" are both wrong; the question is who is waiting for what.

Worker offload economics

The naive worker (s5) was the worst of seven strategies — worse than doing the work synchronously on the main thread. It moved 200k rows across the boundary per keystroke: 1516 ms of structured clone versus 34 ms of actual compute. The resident worker (s6) does the same work with a 4 ms boundary cost and wins every column.

The decision rule that follows:

worker is worth it  ⟺  compute_cost  >  boundary_cost + scheduling_latency

where boundary_cost ≈ structured clone of everything crossing, EACH WAY, EACH CALL

Practical consequences:

  • Ownership, not offload. Ask "where does this data live?" before "should this be in a worker?" If the dataset crosses per operation, you have built a slower program with more moving parts and a second failure surface.
  • Transferables and SharedArrayBuffer change the arithmetic. ArrayBuffer transfer is O(1) rather than O(size), which is why binary-shaped workloads (image processing, parsing, columnar data) suit workers far better than object graphs do. SharedArrayBuffer requires cross-origin isolation (COOP + COEP) — a deployment constraint, not a code change.
  • Structured clone cannot carry functions, DOM nodes, class identity, or cycles-with-identity. Payloads usually need reshaping, and that reshaping is itself main-thread cost.
  • Latency floor. Even a trivial round-trip costs a task boundary each way. For sub-millisecond work, a worker is pure loss.

INP decomposition as a routing table

The benchmark produced at least three different dominant terms across seven implementations of one feature. This is the module's most transferable result, because organisations aggregate INP into a single dashboard number and then debate a single fix.

DominantThe real questionWhere the fix usually lives
input delaywhat was already running?somewhere else entirely — often a different team's code
processingwhy is the handler doing this much?split the handler; defer past a task boundary
presentationhow much are we invalidating?DOM volume, containment, content-visibility, compositor properties

A Principal-level intervention here is usually organisational rather than technical: require the decomposition alongside the number in any performance report, so three teams with the same INP value stop being treated as having the same problem.

Harness failure modes

Both bugs found while building this module produced clean, plausible, entirely fake data. This is the characteristic failure of performance work and is worth internalising more than any individual result.

1. Realm reuse across trials. page.setContent() reuses the JavaScript realm. A top-level const b in trial 2 collides with trial 1's binding, the script throws Identifier 'b' has already been declared, no listeners attach, and the log reads empty — indistinguishable from "the event did not fire." 39 of 40 trials were silently void while the summary table looked orderly. Fixes: IIFE-wrap every page script; assert harness liveness before each trial (loadTrial() throws if window.__log is missing).

2. Per-event instead of per-interaction INP. One keystroke emits keydown, keypress and keyup sharing an interactionId. Taking the longest single event reports processing: 0 even for a fully synchronous handler, because the longest event is keydown, which does no work. Taking the span from first start to last end reports ~250 ms for everything, because it swallows the inter-keystroke gap. Correct: group by interactionId; latency is the longest event's duration; processing is summed across the group.

Both bugs pointed in the right direction while being quantitatively meaningless — the most dangerous kind of wrong, because the conclusion survives review even though the number does not.

Rule: a measurement harness needs its own failing test before you trust a single number from it. Deliberately break the thing being measured and confirm the harness notices.

What breaks at scale

  • Third-party scripts you cannot modify. A vendor SDK responsible for the majority of long tasks is a scheduling problem with no code fix available to you. Options, in ascending blast radius: load it lazily after the critical interaction path; move it behind a facade that loads on intent; sandbox it in a worker or iframe; negotiate a contractual performance requirement; remove it. The technical work is the easy part.
  • Yielding interacts badly with transactional state. Every yield is a point where the DOM, application state, and the user's intent can all change. A chunked operation that mutates shared state across yields needs the same discipline as a concurrent program: either a consistent snapshot taken before the first yield, or explicit revalidation after each one. This is where naive chunking introduces correctness bugs while fixing performance ones.
  • Frameworks yield; your effects do not. React can time-slice rendering. It cannot time-slice 200 ms of parsing inside useEffect. Teams adopt concurrent features, observe no INP change, and conclude the features do not work. The measurement that settles it is the dominant column: framework-level yielding moves pres, and does nothing for proc in an effect.
  • Timer coarsening and cross-origin isolation. performance.now() is deliberately coarsened and SharedArrayBuffer requires COOP+COEP — both Spectre mitigations. Your measurement precision and your worker architecture are both constrained by a security decision the application cannot opt out of.
  • Background tabs. Timers are throttled aggressively in hidden tabs, rAF stops entirely. Any chunked job that assumes it keeps running when the user switches tabs will silently stall. Use visibilitychange explicitly rather than discovering this from a support ticket.