Analysis
Deeper treatment of the decisions this module surfaces. Numbers cited are from
measured-results.md unless stated.
Yield primitives compared
| Primitive | Creates a rendering opportunity? | Priority on resume | Cost | Use when |
|---|---|---|---|---|
await Promise.resolve() / queueMicrotask | No | n/a | ~free | batching state; never for chunking |
setTimeout(fn, 0) | Yes | back of the queue; clamped (≥ 1 ms, 4 ms when nested ≥ 5 deep) | high latency | legacy fallback only |
MessageChannel.postMessage | Yes | ordinary task, no clamping | low | the reliable hand-rolled yield; what React's scheduler uses |
scheduler.postTask({priority}) | Yes | explicit user-blocking / user-visible / background, AbortSignal | low | genuinely competing work classes |
scheduler.yield() | Yes | continuation priority — ahead of new low-priority work, behind input | low per call, but resumes behind rendering | mid-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
SharedArrayBufferchange the arithmetic.ArrayBuffertransfer 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.SharedArrayBufferrequires 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.
| Dominant | The real question | Where the fix usually lives |
|---|---|---|
| input delay | what was already running? | somewhere else entirely — often a different team's code |
| processing | why is the handler doing this much? | split the handler; defer past a task boundary |
| presentation | how 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 thedominantcolumn: framework-level yielding movespres, and does nothing forprocin an effect. - Timer coarsening and cross-origin isolation.
performance.now()is deliberately coarsened andSharedArrayBufferrequires 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,
rAFstops entirely. Any chunked job that assumes it keeps running when the user switches tabs will silently stall. Usevisibilitychangeexplicitly rather than discovering this from a support ticket.