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:

  1. 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.
  2. 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.
  3. 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

CapabilityEvidence you have it
Predict execution order of unfamiliar async codeCorrectly order a mixed sync/await/then/rAF/setTimeout snippet, unaided
Diagnose an INP numberName 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 teamsAnswer "is this network, rendering, or main thread?" with a measurement, not an opinion
Trust a measurementBreak 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 (requestAnimationFrame callbacks → 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 youBecause it would reintroduceSo it gives you instead
Preemptive interruption of your JSData races on the DOMCooperative yield points you insert yourself
Shared mutable objects across threadsLocksStructured clone (copying), or SharedArrayBuffer behind cross-origin isolation
DOM access from a workerLocks on the DOM treeMessage passing, and OffscreenCanvas as the narrow exception
A guarantee your callback runs at time TA real-time schedulerBest-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:

  1. A dashboard reports one aggregate number ("INP is 240 ms").
  2. Because it is one number, it implies one cause, so it gets one owner and one fix.
  3. The fix targets whichever term that engineer knows best — usually bundle size, because it is the most legible.
  4. 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 classFed byPractical consequence
Input / user-blockingReal input dispatch, scheduler.postTask({priority:'user-blocking'})A click can jump ahead of a queue of timers
CompositorFrame production, scrollWhy scroll survives a busy main thread — until it needs you
Default / user-visiblesetTimeout, postMessage, most eventsThe bulk of application work
Best-effort / idlerequestIdleCallback, {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 delay term 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/opacity animation 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 pathObserved orderInterleaved?
Real trusted clickL1 m1 L2 m2 L3 m3Yes
el.click() from a scriptL1 L2 L3 m1 m2 m3No
dispatchEvent(new MouseEvent(...))L1 L2 L3 m1 m2 m3No
el.click() inside a setTimeoutL1 L2 L3 m1 m2 m3No

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 primitiverAF frames renderedwall clock
await Promise.resolve()0401 ms
MessageChannel postMessage48403 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?YesNo — not until the queue is empty
Unbounded chain starves rendering?No — browser renders between themYes — permanent freeze, no error, no crash
Consistency guaranteeWeak; state may change between themStrong; nothing interleaves
Cost per boundaryScheduling latency (~0.1–4 ms)Near-free
Correct usechunking work, yielding to the userbatching 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 layoutReads that force style onlySafe
offsetWidth/Height/Top/LeftgetComputedStyle() (non-geometric)Reading your own JS state
clientWidth/Height/Top/LeftWriting without reading
scrollWidth/Height/Top/Lefttransform / 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 termReal causeFix classWho usually owns the fix
Input delayLong task already running, often unrelated to the interactionChunk/yield elsewhere in the appA different team
ProcessingThe handler does too muchAcknowledge visually now, defer the rest past a task boundaryThe feature team
PresentationHuge DOM, expensive style recalc, forced sync layout, large paintContainment, content-visibility, fewer invalidated elements, compositor-friendly propertiesThe 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

TermDefinition
Task (macrotask)A unit of work the event loop services from a task queue; runs to completion
Task source / task queueThe spec's grouping of tasks; the browser chooses which queue to service — not global FIFO
MicrotaskWork run at a microtask checkpoint; .then, queueMicrotask, await continuations
Microtask checkpointPoint where the queue is drained exhaustively — when the JS execution context stack becomes empty
Rendering opportunityA point between tasks where the browser may run "update the rendering" — skipped for hidden or off-screen content
Update the renderingThe fixed spec sequence: resize/scroll/media/animation steps → rAF → observers → style → layout → paint → composite
Long taskA task ≥ 50 ms; observable via PerformanceObserver type longtask, with poor attribution
LoAFLong Animation Frames — supersedes long tasks; reports blockingDuration, renderStart, per-script attribution
TBTTotal Blocking Time — sum of (taskDuration − 50 ms) over long tasks; rewards boundaries, not speed
INPInteraction to Next Paint; per-interaction, the longest event duration in an interactionId group
interactionIdGroups the events of one interaction (keydown+keypress+keyup) in Event Timing
Forced synchronous layoutReading a geometric property after a style write, forcing style+layout mid-task
Layout thrashingForced synchronous layout in a loop — the 98× case
Compositor threadOff-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 clampingsetTimeout 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 cloneSerialisation across a worker boundary; synchronous main-thread cost, proportional to payload
TransferableOwnership transfer (ArrayBuffer, MessagePort, OffscreenCanvas) — O(1) instead of O(size)
Cross-origin isolationCOOP+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.

  1. Microtask boundary — consistency, no rendering, ~free.
  2. Task boundary — the browser may render and process input; costs scheduling latency.
  3. 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

PrimitiveRendering 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 ≥5high 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 renderinglow per call, but resumes behind renderingmid-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:

SituationWhat the user experiencesDecision
A human is watching incremental feedbackUnresponsiveness; total time is invisibleYield. Responsiveness wins outright
A human is waiting for the completed result and nothing elseTotal time is the experienceDo not yield. Show a determinate progress indicator instead
No human is waiting (background sync, prefetch, export)Nothing, until it blocks something elseYield 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. 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 is 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

  1. await Promise.resolve() or queueMicrotask used 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.
  2. A geometric read inside a loop that also writes. The 98× case. Ask for read/write phases.
  3. 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 is rAF; after layout is ResizeObserver or double-rAF.
  4. new Worker(...) with the dataset in the postMessage payload. Ask for the boundary cost, not the compute cost.
  5. 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

  1. 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.
  2. 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).
  3. Prefer LoAF over longtask. longtask attribution is famously poor — often just the containing frame. LoAF gives blockingDuration, renderStart, and a scripts[] array with per-script invoker and duration.
  4. 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.
  5. 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-level const b in 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+keyup sharing an interactionId. Taking the longest single event reports processing: 0 even 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 by interactionId; 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 moves pres, and does nothing for proc in an effect.
  • Security constraints shape your architecture. performance.now() is deliberately coarsened and SharedArrayBuffer requires 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 rAF stops entirely. Any long job that assumes it keeps running will silently stall.

7. Common misconceptions

  1. "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.

  2. "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, and el.click() inside a setTimeout all 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()/fireEvent have different semantics from real users.

  3. "await costs three microtask ticks." Stale — that was pre-2019. Measured on Chrome 149: exactly one tick (sync A1 P1 A2 P2 A3 P3).

  4. "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 is rAF; after layout, ResizeObserver or double-rAF.

  5. "setTimeout(fn, 0) means zero." It is clamped to ≥1 ms, and to 4 ms once nested five deep — so a setTimeout-based chunker caps at ~250 slices per second no matter how small the slices are. Shrinking the slice past that point changes nothing.

  6. "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.

  7. "Platform primitives are always preferable." A live counterexample to the specification's own heuristic: scheduler.yield() and a hand-rolled MessageChannel yield 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.

  8. "Long tasks tell you what was slow." longtask attribution is famously poor (often just the containing frame). LoAF reports blockingDuration, renderStart, and a scripts[] array with per-script invoker and duration. Prefer LoAF where available.

  9. "rAF is 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.

  10. "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 setTimeout and a postMessage have no defined relative order — which is where "works locally, flaky in CI" async tests come from.

  11. "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.

  12. "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, and delay across 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 via MessageChannel — 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 MessageChannel rather 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. And setTimeout wouldn'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."
  • "requestAnimationFrame isn't faster than setTimeout; 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 (s5 vs s6) 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 the MessageChannel-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.