Broader Ideas
Where this module's mechanism reappears later in the programme, and what it explains.
Hydration is the canonical long task
Server-rendered HTML paints quickly (good LCP), then hydration attaches handlers and rebuilds client state — classically as one uninterruptible task. The result is a page that looks interactive and is not, which is the single most common cause of a good-LCP/bad-INP profile.
Every mitigation is a task-decomposition strategy, and this module lets you evaluate them from first principles rather than by reputation:
| Approach | What it does to the task graph |
|---|---|
| Progressive / selective hydration | splits one long task into many, prioritised by interaction likelihood |
| Islands | eliminates hydration for subtrees that never needed it |
| Server Components | moves component work off the client entirely; nothing to hydrate |
| Streaming SSR | overlaps server work with client parsing; does not by itself shorten hydration |
The last row is the one teams get wrong: streaming improves time-to-first-byte-of-content and
does nothing for hydration cost. If your dominant INP term is proc, streaming will not move it.
Picked up properly in fe-21-rendering-architectures.
React's scheduler as a worked example
packages/scheduler/src/forks/Scheduler.js — read schedulePerformWorkUntilDeadline. It prefers
MessageChannel over both microtasks and setTimeout, for exactly the reasons measured here: a
microtask-based scheduler would have better total throughput and be useless for responsiveness,
and setTimeout is clamped and queued behind everything. It is the tasks-versus-microtasks
asymmetry, chosen deliberately by people who had to live with the consequences.
Worth reading in source rather than in a blog post, and worth logging in
../fe-00-roadmap/docs/learning-log.md §3. Followed up in fe-09 (Fiber, render vs commit) and
fe-12 (concurrent rendering as scheduling).
Debounce is not a race fix
A debounced input reduces the number of in-flight requests. It changes nothing about ordering. If request N returns after request N+1, the user sees results for a query they already replaced — the "stale response overwrites newer data" incident from the specification's case-study list.
The three fixes are not equivalent, and ranking them is the seam into fe-17:
| Fix | Guarantees | Fails when |
|---|---|---|
AbortController cancellation | request is actually cancelled client-side | server already committed a side effect |
| Sequence number / last-write-wins | correct rendering | wasted work; server still processed both |
| Render-time guard (compare response to current state) | correct rendering, simplest | wasted work; no cancellation signal upstream |
For a read, any of the three works and the ranking is about efficiency. For a request with a server-side effect, only cancellation is safe, and even then only if the server honours it — which is why idempotency keys exist and why this becomes a distributed-systems conversation in fe-18.
Scheduling as an accessibility concern
Long tasks are not neutral across users. Focus movement queues behind the blocked main thread, so
a keyboard user pressing Tab gets no response and — unlike a mouse user — has no hover feedback
confirming the app is alive. Screen-reader users additionally suffer from aria-live regions
updated per chunk, which floods the announcement queue. The correct pattern is announcing start
and completion, not progress.
prefers-reduced-motion interacts here too: a rAF-driven busy animation must be suppressible,
which means the jank canary from this module's labs is a debugging tool, not a production
spinner. Developed in fe-24-accessibility.
The compositor as an architectural boundary
The red square in starvation.html keeps spinning through a completely blocked main thread,
because it animates transform on the compositor. This is worth generalising: "the page looks
alive" and "the page is responsive" are independent claims, and most loading indicators assert
the first while users care about the second.
The corollary is the threading rule behind "animate transform, not left" — it is not a style
preference, it is which thread owns the animation. And it is why a single non-passive wheel or
touchstart listener hands your scroll performance to your worst long task: the compositor must
ask the main thread whether preventDefault() will be called. Picked up in fe-11 and
fe-27-mobile-web.
Scheduling shows up in test infrastructure
Experiment B has a direct consequence for testing: .click() and fireEvent do not
interleave microtasks between listeners, while real user input does. A test suite driving UI
synthetically has different semantics from production. If any listener's correctness depends on
another's promise having settled, the suite is structurally incapable of catching it.
The mitigation is knowing which of your tests use real input (Playwright's page.click() goes
through CDP and is trusted) versus synthetic dispatch (Testing Library's fireEvent;
userEvent is closer but still synthetic). Developed in fe-32 and fe-33.
Scheduling as an SLO
Once you can decompose INP, you can argue about which metric an organisation should commit to. INP is user-centric but lagging and sparse; Total Blocking Time is lab-measurable and leading but does not reflect real interaction patterns. The mature position is to pair them: a field metric for truth and a lab metric for CI gating, with the explicit acknowledgement that the lab metric is a proxy. Developed in fe-43-observability and fe-44-reliability.