Concepts — Execution Model, Scheduling & the Rendering Pipeline
Phase 1 · Platform substrate · Specification areas §1 (event loop, microtasks, macrotasks, promises, async/await), §2 (browser scheduling, rendering pipeline). Gates §11 (performance), §5 (concurrent rendering), §8/§9 (server state and races), §31 (rendering architectures).
0. Orientation
The 60-second version
A browser tab has one thread that can touch the DOM. Everything the user perceives — JavaScript, style, layout, paint, input dispatch — competes for it. The event loop is the cooperative scheduler that arbitrates that competition. It has no ability to preempt: once your code starts, it runs until it returns. Every "the page froze", "the click did nothing", "the animation stuttered" bug is the same bug — something occupied the thread across a moment the user could perceive.
Three ideas carry the whole module:
- Tasks are interruptible boundaries; microtasks are not. The browser may render between tasks. It may never render in the middle of draining microtasks. This single asymmetry explains why one "chunking" strategy renders 48 frames and a structurally identical one renders 0.
- Rendering is a fixed sequence at a fixed cadence, not something that happens when you ask. Knowing where in that sequence your code runs is the difference between a callback that reads correct geometry and one that forces the browser to redo work.
- Responsiveness and throughput are opposed, and which one you optimise is a product question, not a technical one. Yielding makes the total slower. Measured here: two strategies with identical INP (32 ms) differed 3.4× in wall clock.
What you should be able to do after this module
| Capability | Evidence you have it |
|---|---|
| Predict execution order of unfamiliar async code | Correctly order a mixed sync/await/then/rAF/setTimeout snippet, unaided |
| Diagnose an INP number | Name which of the three terms dominates before proposing any fix |
| Review a "performance fix" | Spot microtask chunking, setTimeout(0) layout hacks, and naive worker offload in a diff |
| Arbitrate across teams | Answer "is this network, rendering, or main thread?" with a measurement, not an opinion |
| Trust a measurement | Break the thing being measured and confirm the harness notices |
The map
INPUT ──► [ event loop picks a task ] ──► your JS runs to completion
│ │
│ (microtask checkpoint)
│ │
└──────► rendering opportunity? ──► rAF
observers
style
layout
paint
composite ──► PIXELS
Sections 1–3 build this diagram. Sections 4–5 make it memorable. Section 6 is what you actually do at work.
1. What is it
The execution model is the set of rules determining when JavaScript runs, when the browser is permitted to draw, and in what order queued work is serviced. It has three components:
- The event loop — a cooperative scheduler that services one task at a time, drains a microtask queue between tasks, and renders only at permitted points.
- The rendering pipeline — the fixed sequence (
requestAnimationFramecallbacks → observer delivery → style → layout → paint → composite) that converts DOM and CSSOM state into pixels. - The scheduling policy — how the browser prioritises competing work: input over compositor over default over idle, with dynamic policy changes under load.
These are not three subjects. They are one mechanism, and nearly every "performance problem", "race condition", and "why didn't it re-render" question in frontend engineering is a question about it.
Why the platform is built this way
It is worth knowing that the single-threaded, run-to-completion model is a deliberate design choice with a specific payoff, not a historical accident nobody got around to fixing.
The payoff is that you never need a lock. Because no other code can run between two
statements of your function, every JavaScript function is implicitly a critical section. i++ is
atomic. A half-updated object is never observable. An entire category of bug — data races, torn
reads, deadlock, the need to reason about memory ordering — simply does not exist in the language
as most people write it.
The price is that there is no preemption, so a slow function is indistinguishable from a
crashed one, and the platform cannot rescue you. Every scheduling API in this module —
requestIdleCallback, scheduler.postTask, scheduler.yield, workers, MessageChannel — exists
to sell back a little of the concurrency that decision gave away, without reintroducing locks.
Understanding this trade explains the shape of every API you will meet:
| The platform will not give you | Because it would reintroduce | So it gives you instead |
|---|---|---|
| Preemptive interruption of your JS | Data races on the DOM | Cooperative yield points you insert yourself |
| Shared mutable objects across threads | Locks | Structured clone (copying), or SharedArrayBuffer behind cross-origin isolation |
| DOM access from a worker | Locks on the DOM tree | Message passing, and OffscreenCanvas as the narrow exception |
| A guarantee your callback runs at time T | A real-time scheduler | Best-effort ordering and priority hints |
A worker is not "a second main thread." It is a second realm with no DOM, communicating by copying. That is why offload decisions in §6.3 are dominated by the boundary, not the compute.
2. Why it matters
Interaction to Next Paint is a scheduling metric. INP decomposes into input delay + processing + presentation. Two of the three terms are pure scheduling, and each has a different fix. A team optimising bundle size to fix an input-delay problem will work for a quarter and move nothing. Redirecting that work in one conversation requires this model.
Async correctness is an ordering claim. "Stale response overwrites newer data" — one of the twelve failure case studies in the specification — is the statement completion order is not issue order. Every fix (cancellation, sequence numbers, render-time guards) is a different answer to "which ordering guarantee do we actually need?" You cannot review that fix without knowing which guarantees the platform provides and which the framework invented.
Modern rendering architecture is applied scheduling. Concurrent rendering, transitions, Suspense, streaming SSR, partial hydration, islands, and Server Components are all strategies for breaking work into more, smaller tasks — or eliminating tasks entirely. Evaluating whether any of them is worth its complexity is impossible without the substrate. Teams routinely adopt concurrent features, observe no INP improvement, and conclude the features do not work; the actual cause is expensive work in an effect, which does not yield.
It is the arbitration a Principal owns. "Is this a network problem, a rendering problem, or a main-thread problem?" Senior engineers own their component. You own that answer across teams, and getting it wrong sends several engineers in the wrong direction for a sprint.
The organisational failure this module prevents
Performance work fails organisationally in a predictable shape, and this module is the antidote to each stage:
- A dashboard reports one aggregate number ("INP is 240 ms").
- Because it is one number, it implies one cause, so it gets one owner and one fix.
- The fix targets whichever term that engineer knows best — usually bundle size, because it is the most legible.
- The number does not move. The conclusion drawn is "performance work is not tractable."
The intervention is not a better fix. It is refusing to accept the number without its decomposition. In this module's own benchmark, seven implementations of the same feature produced at least three different dominant terms. Those are three different bugs. Shipping one fix to all of them is guaranteed to waste two-thirds of the effort — and it is the single most common way a quarter of performance work produces nothing.
3. How it works
+==================================================================+
| THE EVENT LOOP (one main thread, cooperative, no preemption) |
+==================================================================+
while (browser is alive) {
+----------------------------------------------------------+
| 1. PICK ONE TASK |
| from ONE of several prioritised task queues: |
| input > compositor > default > best-effort/idle |
| There is NO single global FIFO. |
+----------------------------------------------------------+
|
+----------------------------------------------------------+
| 2. RUN IT TO COMPLETION |
| No preemption. A 400ms task = 400ms frozen UI. |
| No framework can change this from inside the task. |
+----------------------------------------------------------+
|
+----------------------------------------------------------+
| 3. DRAIN THE MICROTASK QUEUE — EXHAUSTIVELY |
| including microtasks enqueued DURING the drain. |
| Rule: runs whenever the JS execution context stack |
| becomes empty (NOT merely "at end of task"). |
| An unbounded microtask chain never exits this step. |
+----------------------------------------------------------+
|
+----------------------------------------------------------+
| 4. IF THIS IS A RENDERING OPPORTUNITY: |
| |
| requestAnimationFrame callbacks <- BEFORE layout |
| | |
| ResizeObserver / IntersectionObserver delivery |
| | (may loop; deliberately drops |
| | rather than hanging) |
| style -> layout -> paint -> composite |
| |
| ~16.7ms apart at 60Hz, ~8.3ms at 120Hz |
+----------------------------------------------------------+
|
+----------------------------------------------------------+
| 5. IF TIME REMAINS BEFORE NEXT FRAME: |
| requestIdleCallback callbacks |
+----------------------------------------------------------+
}
3.1 Step 1 in detail — there is no single queue
The most common wrong mental model is a single global FIFO. The spec says an event loop has multiple task queues, each fed by a task source, and step 1 is "choose a task queue" — the choice is left to the implementation, subject to the constraint that order is preserved within a queue, and only within a queue.
Chromium implements that choice as a priority scheme over queue types:
| Priority class | Fed by | Practical consequence |
|---|---|---|
| Input / user-blocking | Real input dispatch, scheduler.postTask({priority:'user-blocking'}) | A click can jump ahead of a queue of timers |
| Compositor | Frame production, scroll | Why scroll survives a busy main thread — until it needs you |
| Default / user-visible | setTimeout, postMessage, most events | The bulk of application work |
| Best-effort / idle | requestIdleCallback, {priority:'background'} | Runs only in genuine slack; starves under load, by design |
Two consequences you can act on:
Ordering guarantees are per-source, not global. Two setTimeout(fn, 0) calls run in
registration order — same source, same queue. A setTimeout(fn, 0) and a postMessage have no
guaranteed relative order, because they are different sources and the browser may service either
queue. Code that depends on that relative order is depending on an implementation detail, and it
will differ across browsers and across load conditions in the same browser. This is the actual
root cause of most "works locally, fails in CI" async test flakes.
Timer clamping is a hard ceiling, not a hint. setTimeout(fn, 0) does not mean zero. It is
clamped to ≥1 ms, and nested timers beyond depth 5 are clamped to 4 ms. A setTimeout-based
chunker therefore silently caps at roughly 250 slices per second, regardless of how small you
make the slice. Halving your slice size does not double your throughput; past that point it does
nothing at all. This is why MessageChannel (unclamped) is the primitive React's scheduler uses,
and it is not a micro-optimisation — it is a different order of magnitude.
3.2 Step 2 in detail — run-to-completion, and what "blocked" means
There is no mechanism by which the browser can interrupt a running task. Not a timer, not a priority, not a framework. Consequences worth stating explicitly, because each is a bug class:
- A long task blocks input dispatch itself, not just your handlers. The click is queued by the
OS and cannot even begin to be delivered. This is the
input delayterm of INP, and it is why the fix is usually in code that has nothing to do with the button. - It blocks rendering. No style, no layout, no paint. A CSS animation driven by the main
thread stops; a
transform/opacityanimation on the compositor keeps going. That difference is the single most useful diagnostic in the field: if the spinner still spins while the page is dead, the spinner is composited and the main thread is blocked. - It blocks other frames on the same event loop. Same-origin iframes share an event loop with their opener. A third-party widget in a same-origin iframe can freeze your page.
alert,confirm, and synchronous XHR are the same problem wearing a native costume.
"Long task" has a specific definition: ≥50 ms. That threshold is not arbitrary — it comes from
the RAIL guidance that a response within 100 ms feels immediate, with 50 ms reserved for the task
already running and 50 ms for handling the new input. Total Blocking Time sums
(taskDuration − 50 ms) over long tasks, which is why splitting a 100 ms task into two 50 ms tasks
takes TBT from 50 to 0 while doing exactly the same work. TBT rewards the boundary, not the speed —
that is the metric working as intended, but it also means TBT can be gamed, and you should know
that when someone shows you a TBT win with no INP change.
3.3 Step 3 in detail — the microtask checkpoint rule
The rule most engineers carry is "microtasks run at the end of each task." It is close enough to be dangerous. The actual rule is: a microtask checkpoint is performed when the JavaScript execution context stack becomes empty.
That distinction is directly observable, and this module measures it. Three listeners on one element, each queueing a microtask:
| Dispatch path | Observed order | Interleaved? |
|---|---|---|
| Real trusted click | L1 m1 L2 m2 L3 m3 | Yes |
el.click() from a script | L1 L2 L3 m1 m2 m3 | No |
dispatchEvent(new MouseEvent(...)) | L1 L2 L3 m1 m2 m3 | No |
el.click() inside a setTimeout | L1 L2 L3 m1 m2 m3 | No |
The fourth row is the one that matters. It rules out "task vs script" as the explanation — a
setTimeout callback is its own task. The real discriminator is the stack: browser-initiated
dispatch returns to an empty stack between listeners, so a checkpoint runs between each. Script-
initiated dispatch still has your frame on the stack, so no checkpoint occurs until your function
returns.
Why this is not trivia. It means fireEvent / .click() in a test suite has different
semantics from a real user, in a way that can hide or manufacture bugs. A component whose
correctness depends on a promise settling between two listeners will pass tests and fail in
production, or the reverse. When someone says "it works in the test but not in the browser" about
an ordering-sensitive component, this is the first thing to check — and it is the reason
user-event (which drives real dispatch paths) exists as a separate library from fireEvent.
The starvation property. The queue must be drained to empty, including microtasks enqueued during the drain, before the loop can proceed to rendering. A self-enqueueing microtask chain therefore never exits step 3. There is no error, no watchdog, no crash — the tab is simply gone. Compare the two failure modes, because they are wildly asymmetric:
// Hot CPU. Page stays responsive. Browser renders between every iteration.
function loop() { doWork(); setTimeout(loop, 0); }
// Tab must be killed. No error. No frame ever renders again.
function loop() { doWork(); queueMicrotask(loop); }
Measured (src/run-starvation.mjs, 400 ms of work in 5 ms slices, identical structure):
| Yield primitive | rAF frames rendered | wall clock |
|---|---|---|
await Promise.resolve() | 0 | 401 ms |
MessageChannel postMessage | 48 | 403 ms |
Same slice budget, same total time, same code shape. Microtasks are not a queue you yield to; they are a hole you can fall into.
3.4 The asymmetry that does all the work
Macrotask (setTimeout, events, MessageChannel) | Microtask (.then, queueMicrotask, await) | |
|---|---|---|
| Can the browser render after it? | Yes | No — not until the queue is empty |
| Unbounded chain starves rendering? | No — browser renders between them | Yes — permanent freeze, no error, no crash |
| Consistency guarantee | Weak; state may change between them | Strong; nothing interleaves |
| Cost per boundary | Scheduling latency (~0.1–4 ms) | Near-free |
| Correct use | chunking work, yielding to the user | batching state, maintaining invariants |
The right way to read this table is that each column is correct for its own job and catastrophic
for the other's. Microtasks exist so that a framework can batch N state updates and flush once
with a guarantee that nothing interleaved — that is what makes await composable and what makes
React's batching sound. Using them to yield inverts every property you wanted.
3.5 Step 4 in detail — "update the rendering"
The rendering steps are a fixed, ordered sequence. Their order is the entire explanation for a family of otherwise baffling bugs.
rendering opportunity
│
├─ 1. run the resize steps ─┐
├─ 2. run the scroll steps │ fire before your rAF
├─ 3. evaluate media queries │
├─ 4. update animations, send events ┘
│
├─ 5. requestAnimationFrame callbacks ◄── your code, BEFORE layout
│
├─ 6. ResizeObserver delivery ─┐ may loop; the spec deliberately
├─ 7. IntersectionObserver delivery ┘ DROPS an update rather than hang
│
├─ 8. style ── recompute which rules match, resolve values
├─ 9. layout ── compute geometry
├─ 10. paint ── record draw commands
└─ 11. composite ── assemble layers, hand to GPU
Four things follow directly from that ordering:
rAF runs before layout. So geometry you read inside a rAF callback reflects the previous
frame. Reading it is cheap (nothing is dirty yet if you have not written). Writing then reading in
the same callback is a forced synchronous layout (§3.6).
ResizeObserver runs after rAF but before paint. This is what makes it the correct tool for
"measure, then adjust before the user sees anything" — you get a chance to mutate and have it
included in the same frame. ResizeObserver was specified precisely to kill the
resize-listener-plus-setTimeout pattern, and it has a loop-protection rule: if your handler
causes another resize, the browser will deliver once more and then emit
ResizeObserver loop completed with undelivered notifications rather than hang. That console
error is not noise — it means a frame was dropped and your UI is one frame stale.
"Rendering opportunity" is a decision, not a guarantee. The browser skips the whole block when
there is no point: hidden tabs, display:none iframes, cross-origin iframes off-screen, and
whenever it decides nothing changed. So rAF is not a timer. In a background tab, rAF stops
entirely and timers are heavily throttled. Any chunked job that assumes it keeps running when the
user switches tabs will silently stall, and this arrives as an unreproducible support ticket.
Handle visibilitychange deliberately rather than discovering it in the field.
Frame budget is not 16.7 ms of yours. At 60 Hz the whole pipeline — your JS, style, layout, paint, composite — must fit in 16.7 ms. On a 120 Hz display it is 8.3 ms. Budget roughly half for your own work, and note that the mid-tier Android device your users have is typically 4–6× slower than your laptop, which is why this module's benchmark applies 6× CPU throttling.
3.6 Forced synchronous layout — the one-line bug
When you write to the DOM, the browser marks style/layout dirty and defers the work to the next rendering opportunity. When you read a geometric property, the browser must give you a correct answer now, which means flushing that deferred work synchronously, mid-task.
One write followed by one read is fine. A loop of write-then-read forces a full style+layout per iteration:
// 98 ms across 800 elements — one layout per element
for (const el of elements) {
el.style.width = el.offsetWidth + 1 + 'px'; // write, then read, then write...
}
// 1 ms — identical final DOM
const widths = elements.map(el => el.offsetWidth); // read phase: one layout
elements.forEach((el, i) => el.style.width = widths[i] + 1 + 'px'); // write phase
98×. Same work, same elements, same final DOM. Only the ordering changed.
This is the most important bug in the module for a reviewer, because it fits on one line and reads as clean code. There is no smell. The fix is always the same shape — separate the read phase from the write phase — and the discipline that prevents it is knowing the trigger list:
| Reads that force layout | Reads that force style only | Safe |
|---|---|---|
offsetWidth/Height/Top/Left | getComputedStyle() (non-geometric) | Reading your own JS state |
clientWidth/Height/Top/Left | Writing without reading | |
scrollWidth/Height/Top/Left | transform / opacity (compositor) | |
getBoundingClientRect() | ||
getClientRects() | ||
window.getComputedStyle() (geometric props) | ||
focus(), scrollIntoView() |
Two field notes. First, the framework does not save you: React batches its own writes, but if
your effect reads offsetHeight between two committed updates, you have thrashed. Second, the
modern escape hatch for genuinely needing measurement is to not measure — ResizeObserver,
IntersectionObserver, container queries and aspect-ratio exist to answer geometry questions
declaratively, and they run inside the pipeline instead of interrupting it.
3.7 Where INP comes from
INP = input delay (a task was already running when the user acted)
+ processing time (your listeners)
+ presentation delay (style/layout/paint/composite to next frame)
| Dominant term | Real cause | Fix class | Who usually owns the fix |
|---|---|---|---|
| Input delay | Long task already running, often unrelated to the interaction | Chunk/yield elsewhere in the app | A different team |
| Processing | The handler does too much | Acknowledge visually now, defer the rest past a task boundary | The feature team |
| Presentation | Huge DOM, expensive style recalc, forced sync layout, large paint | Containment, content-visibility, fewer invalidated elements, compositor-friendly properties | The design-system or platform team |
The fourth column is why this decomposition is an organisational tool and not just a debugging one. An input-delay-dominated INP problem cannot be fixed by the team that owns the button, and telling them to fix it guarantees a quarter of wasted work.
4. Core terminology
| Term | Definition |
|---|---|
| Task (macrotask) | A unit of work the event loop services from a task queue; runs to completion |
| Task source / task queue | The spec's grouping of tasks; the browser chooses which queue to service — not global FIFO |
| Microtask | Work run at a microtask checkpoint; .then, queueMicrotask, await continuations |
| Microtask checkpoint | Point where the queue is drained exhaustively — when the JS execution context stack becomes empty |
| Rendering opportunity | A point between tasks where the browser may run "update the rendering" — skipped for hidden or off-screen content |
| Update the rendering | The fixed spec sequence: resize/scroll/media/animation steps → rAF → observers → style → layout → paint → composite |
| Long task | A task ≥ 50 ms; observable via PerformanceObserver type longtask, with poor attribution |
| LoAF | Long Animation Frames — supersedes long tasks; reports blockingDuration, renderStart, per-script attribution |
| TBT | Total Blocking Time — sum of (taskDuration − 50 ms) over long tasks; rewards boundaries, not speed |
| INP | Interaction to Next Paint; per-interaction, the longest event duration in an interactionId group |
interactionId | Groups the events of one interaction (keydown+keypress+keyup) in Event Timing |
| Forced synchronous layout | Reading a geometric property after a style write, forcing style+layout mid-task |
| Layout thrashing | Forced synchronous layout in a loop — the 98× case |
| Compositor thread | Off-main-thread scroll and transform/opacity animation; unblocked by main-thread jank |
| Passive listener | {passive:true}; promises no preventDefault(), letting scroll stay off the main thread |
| Timer clamping | setTimeout minimum ≥1 ms; 4 ms once nested ≥5 deep — caps setTimeout chunkers at ~250 slices/sec |
scheduler.yield() | Yields with continuation priority — resumes ahead of newly-arrived low-priority work, behind rendering |
scheduler.postTask() | Explicit user-blocking/user-visible/background priorities with AbortSignal |
isInputPending() | Reports whether input is queued; a trap for chunking, because rendering is not input |
| Structured clone | Serialisation across a worker boundary; synchronous main-thread cost, proportional to payload |
| Transferable | Ownership transfer (ArrayBuffer, MessagePort, OffscreenCanvas) — O(1) instead of O(size) |
| Cross-origin isolation | COOP+COEP; the deployment precondition for SharedArrayBuffer and high-resolution timers |
5. Mental models
The one sentence. Anything between two task boundaries is invisible to the user and blocking their input. Every optimisation here is a variation on: make the gaps between task boundaries smaller.
Three clocks, never conflated.
- Microtask boundary — consistency, no rendering, ~free.
- Task boundary — the browser may render and process input; costs scheduling latency.
- Frame boundary — ~16.7 ms at 60 Hz; the only clock the user perceives.
Much confused performance work optimises clock 1 while the user experiences clock 3. When someone shows you a benchmark, the first question is which clock did you measure?
rAF is frame-relative, not fast. requestAnimationFrame is not "quicker than setTimeout".
Its latency is your distance to the next rendering opportunity, and that distance is determined by
what scheduled you. Measured: from inside a click handler, rAF fires at +2–7 ms and
setTimeout(0) at +2.1–7.0 ms (rAF wins); from a plain script, rAF fires at +3–11 ms and
setTimeout(0) at +0.0 ms (the timer wins). Input dispatch is scheduled immediately before the
rendering steps — that is the entire explanation. The spec guarantees nothing about their
relative order; the observed order is a function of where in the frame cycle the initiating task
sits.
async does not mean "in the background". It is main-thread, cooperative, microtask-scheduled.
An async function awaiting 200 already-resolved promises while doing work between them is one
long task, and nothing renders during it. async marks a suspension point, not a thread.
Workers move computation, not data. The boundary is the cost. Structured clone is synchronous main-thread work proportional to payload size. Measured: an identical worker filtering 200k rows cost 1502 ms of clone when the dataset crossed per keystroke, and 4 ms when the dataset lived in the worker and only the query crossed. Same worker, same algorithm, 376× difference from one architectural decision.
Responsiveness is bought with throughput, and you must know who is paying. Every yield costs a
scheduling round-trip and pushes total completion later. That is not a bug to optimise away — it is
the trade you are making. Measured: scheduler.yield() and a hand-rolled MessageChannel yield hit
identical INP (32 ms), but the platform primitive took 3.4× the wall clock (1562 ms vs
466 ms). Neither is wrong. The question is who is waiting for what (§6.2).
Yielding is a concurrency boundary, so treat it like one. Every yield is a point where the DOM, application state, and the user's intent can all change. Chunked work that mutates shared state across yields needs the discipline of a concurrent program: take a consistent snapshot before the first yield, or explicitly revalidate after each one. This is how naive chunking introduces correctness bugs while fixing performance ones — and it is the failure mode a reviewer should be looking for once someone has correctly switched from microtasks to tasks.
6. Applied: the decisions this module actually makes
6.1 Choosing a yield primitive
| Primitive | 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 | 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 rendering | low per call, but resumes behind rendering | mid-task yielding where responsiveness dominates |
Why isInputPending() is a trap. It yields only when input is actually waiting, which
maximises throughput — and 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 roughly every 5 ms of work), which is insensitive to whether anyone
happens to be clicking.
6.2 Should this yield at all?
This is the question that produces the most legitimate disagreement, because the engineer arguing against yielding has a real benchmark showing it is slower. They are not wrong about their number. Resolve it by establishing which number represents the user:
| Situation | What the user experiences | Decision |
|---|---|---|
| A human is watching incremental feedback | Unresponsiveness; total time is invisible | Yield. Responsiveness wins outright |
| A human is waiting for the completed result and nothing else | Total time is the experience | Do not yield. Show a determinate progress indicator instead |
| No human is waiting (background sync, prefetch, export) | Nothing, until it blocks something else | Yield only enough to not block interactions that may arrive |
"Always yield" and "never yield" are both wrong. The question is who is waiting for what.
6.3 Should this go in a worker?
worker is worth it ⟺ compute_cost > boundary_cost + scheduling_latency
where boundary_cost ≈ structured clone of everything crossing, EACH WAY, EACH CALL
The naive worker was the worst of seven strategies measured — worse than doing the work synchronously on the main thread — because per-operation structured clone (1502 ms) exceeded the computation being offloaded (33 ms). The practical rules:
- 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. Ownership, not offload.
- Transferables change 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. SharedArrayBufferis a deployment decision, not a code change. It requires cross-origin isolation (COOP + COEP), which can break third-party embeds across the whole site.- 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 you must count.
- There is a latency floor. Even a trivial round-trip costs a task boundary each way. For sub-millisecond work a worker is pure loss.
6.4 Reviewing a diff — the five smells
await Promise.resolve()orqueueMicrotaskused to "yield" or "chunk". Not a yield. Measured: 0 frames, 8 long tasks, 865 ms TBT, 3× the INP of doing nothing, and 5.5× the wall clock of task-based chunking. This is a regression that reads as an optimisation, and it is the single highest-value catch in this module.- A geometric read inside a loop that also writes. The 98× case. Ask for read/write phases.
setTimeout(fn, 0)used to fix a timing or layout problem. It works by accident and breaks under load — you did not choose a phase of the frame cycle, you chose a coin flip whose bias depends on scheduling pressure. Before layout isrAF; after layout isResizeObserveror double-rAF.new Worker(...)with the dataset in thepostMessagepayload. Ask for the boundary cost, not the compute cost.- A chunked loop that mutates shared state across its yields, with no snapshot or revalidation. Correct performance, new correctness bug.
6.5 Diagnosing in the field — the ordered checklist
- Is the main thread blocked at all? If a composited animation (
transform/opacity) still runs while the page is dead, yes. This one observation splits the search space in half before you open a profiler. - Get the INP decomposition, not the INP number. Which of the three terms dominates? That selects the fix class and, usually, the owning team (§3.7).
- Prefer LoAF over
longtask.longtaskattribution is famously poor — often just the containing frame. LoAF givesblockingDuration,renderStart, and ascripts[]array with per-script invoker and duration. - Check for forced synchronous layout in the trace. Chromium marks it explicitly — a style or layout bar nested inside a scripting block, flagged with a warning triangle.
- Only then profile. Steps 1–4 usually name the file.
6.6 Trusting your own measurements
Both harness bugs found while building this module produced clean, plausible, entirely fake data — and both pointed in the right direction while being quantitatively meaningless, which is the most dangerous kind of wrong, because the conclusion survives review even though the number does not.
- Realm reuse across trials.
page.setContent()reuses the JavaScript realm, so a top-levelconst bin trial 2 collides with trial 1's, the script throws, 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. Fix: IIFE-wrap page scripts; assert harness liveness before each trial. - Per-event instead of per-interaction INP. One keystroke emits
keydown+keypress+keyupsharing aninteractionId. Taking the longest single event reportsprocessing: 0even for a fully synchronous handler; taking the span from first start to last end reports ~250 ms for everything, because it swallows the inter-keystroke gap. Correct: group byinteractionId; latency is the longest event's duration; processing is summed across the group.
The rule this establishes: a measurement harness needs its own failing test before you trust a single number it produces. Deliberately break the thing being measured and confirm the harness notices.
6.7 What breaks at organisational 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; put 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.
- 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-term column: framework-level yielding movespres, and does nothing forprocin an effect. - Security constraints shape your architecture.
performance.now()is deliberately coarsened andSharedArrayBufferrequires COOP+COEP — both Spectre mitigations. Your measurement precision and your worker architecture are constrained by a decision the application cannot opt out of. - Background tabs. Timers are throttled aggressively in hidden tabs and
rAFstops entirely. Any long job that assumes it keeps running will silently stall.
7. Common misconceptions
-
"Microtasks yield to the event loop." They do not. The microtask queue must be empty before the browser may reach the rendering steps; a self-enqueueing chain never empties. Measured: microtask "chunking" produced 0 rendered frames, 8 long tasks, 865 ms TBT, 3× the INP of the naive baseline, and 5.5× the wall clock of task-based chunking. It reads like the careful version in code review and is a large regression.
-
"A microtask checkpoint runs at the end of each task." Close enough to be dangerous. The rule is when the JS execution context stack becomes empty. Measured across four dispatch paths — real click interleaves microtasks between listeners (
L1 m1 L2 m2 L3 m3);el.click(),dispatchEvent, andel.click()inside asetTimeoutall do not (L1 L2 L3 m1 m2 m3). The last case is its own task, which rules out "task vs script" as the discriminator. This is why tests driving UI with.click()/fireEventhave different semantics from real users. -
"
awaitcosts three microtask ticks." Stale — that was pre-2019. Measured on Chrome 149: exactly one tick (sync A1 P1 A2 P2 A3 P3). -
"
setTimeout(fn, 0)fixes layout timing." It works by accident and breaks under load. You did not choose a phase of the frame cycle; you chose a coin flip whose bias depends on scheduling pressure. If you need to run before layout, that isrAF; after layout,ResizeObserveror double-rAF. -
"
setTimeout(fn, 0)means zero." It is clamped to ≥1 ms, and to 4 ms once nested five deep — so asetTimeout-based chunker caps at ~250 slices per second no matter how small the slices are. Shrinking the slice past that point changes nothing. -
"Moving work to a worker makes it faster." Only if the data boundary is designed. A naive worker was the worst of seven strategies measured — worse than doing nothing on the main thread — because per-operation structured clone exceeded the computation being offloaded.
-
"Platform primitives are always preferable." A live counterexample to the specification's own heuristic:
scheduler.yield()and a hand-rolledMessageChannelyield produced identical INP (32 ms), but the platform primitive took 3.4× the wall clock (1562 ms vs 466 ms), because its continuations are scheduled behind rendering. Correct default, real cost. -
"Long tasks tell you what was slow."
longtaskattribution is famously poor (often just the containing frame). LoAF reportsblockingDuration,renderStart, and ascripts[]array with per-script invoker and duration. Prefer LoAF where available. -
"
rAFis a 60 fps timer." It is a rendering opportunity callback. It does not fire in a hidden tab, its cadence follows the display (120 Hz → 8.3 ms), and it is skipped entirely when the browser decides not to render. Code that uses it to schedule non-visual work stalls silently when the tab is backgrounded. -
"There is one task queue, so ordering is FIFO." There are many, and the browser chooses. Order is guaranteed within a task source only. A
setTimeoutand apostMessagehave no defined relative order — which is where "works locally, flaky in CI" async tests come from. -
"
isInputPending()is the smart way to chunk." It yields only for input. Rendering is not input, so a loop using it can be click-responsive and visually frozen for seconds. -
"TBT and INP measure the same thing." TBT sums
(duration − 50 ms)over long tasks and is a load-time lab metric; INP is a per-interaction field metric. Splitting a task in half can take TBT to zero while INP is unchanged. Treat a TBT win with no INP movement as unproven.
8. Interview talking points
- "INP decomposes into input delay, processing, and presentation, and those are three different
bugs with three different fixes — often owned by three different teams. When a team shows me one
INP number I ask which term dominates before I let anyone start optimising. In our own benchmark
the dominant term moved between
proc,pres, anddelayacross seven strategies solving the same problem." - "Chunking work with
await Promise.resolve()doesn't chunk anything. The microtask queue has to drain to empty before the browser can render, so a self-enqueueing chain never yields. We measured it: zero frames rendered over 400 ms, versus 48 for the same loop yielding viaMessageChannel— and in the full benchmark the microtask version was worse than doing nothing, 3× the INP of the naive baseline." - "Whether to move work to a worker is a data-ownership decision, not a compute decision. Structured clone is synchronous main-thread cost. We measured a 376× difference between posting the dataset per keystroke and keeping it resident in the worker — same worker, same algorithm."
- "React's scheduler yields with
MessageChannelrather than microtasks, deliberately: a microtask-based scheduler would have better total throughput and be useless for responsiveness. That design choice is the tasks-versus-microtasks asymmetry. AndsetTimeoutwouldn't work either, because nested timers clamp to 4 ms." - "Layout thrashing is invisible in code review because the bug fits on one line —
e.style.width = e.offsetWidth + 1 + 'px'is 98× slower across 800 elements than batching the reads and writes, with an identical final DOM." - "
requestAnimationFrameisn't faster thansetTimeout; it's frame-relative. Its latency is your distance to the next rendering opportunity, which is set by whatever scheduled you. Input dispatch sits right before the rendering steps, which is why rAF beats a timer inside a click handler and loses to it in a plain script." - "Yielding buys responsiveness with throughput. We had two strategies with identical 32 ms INP and a 3.4× wall-clock difference. Neither is wrong — the question is whether a human is watching incremental feedback or waiting for a final result, and that's a product question I'd want answered before I let anyone pick."
- "The first diagnostic I run isn't a profiler. If a composited animation is still moving while the page is unresponsive, the main thread is blocked and the compositor is fine — that halves the search space before anyone opens a trace."
- "We found two harness bugs that produced clean, plausible, entirely fake data while pointing in the right direction. That's the characteristic failure of performance work, and it's why I ask for a harness's own failing test before I'll act on a number it produces."
9. Connections to other modules
fe-02-memory-model(next) — the other Phase-1 module gated only by this one. Retention and leaks are about what survives across tasks; this module establishes what a task is.fe-11-performance-engineering— hard prerequisite. Core Web Vitals, budgets, and quantitative claims are unreadable without INP decomposition.fe-12-concurrent-rendering— Suspense and transitions are scheduling abstractions; this module is the substrate they abstract over.fe-16/fe-17(server state, races) — "completion order is not issue order" starts here; cancellation, sequence numbers and render-time guards are the architectural answers.fe-21-rendering-architectures— hydration is the canonical long task. Partial hydration, islands, and RSC are all task-decomposition strategies.fe-28-workers-parallelism— the worker boundary economics measured here (s5vss6) generalise to the whole offload decision.bi-11-scheduling— the same scheduler from the inside: why each queue priority exists, and how Chromium implements the policy this module observes from outside.fw-04-mini-react-scheduling— you build theMessageChannel-based time-slicing scheduler whose behaviour is measured here, including lane priorities and expiration.browser-framework-internals.md§16 (Browser Scheduling), §5 (Rendering Pipeline) — cross only when you need to know why a queue priority exists, not merely that it does.