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).
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.
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.
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 |
+----------------------------------------------------------+
}
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 |
| Correct use | chunking work, yielding to the user | batching state, maintaining invariants |
An infinite setTimeout loop produces a hot CPU and a responsive page. An infinite
queueMicrotask loop produces a tab that must be killed. Microtasks are not a queue you yield
to; they are a hole you can fall into.
Measured (src/run-starvation.mjs, 400ms of work in 5ms slices, identical structure):
| Yield primitive | rAF frames rendered | wall clock |
|---|---|---|
await Promise.resolve() | 0 | 401 ms |
MessageChannel postMessage | 48 | 403 ms |
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 |
|---|---|---|
| Input delay | Long task already running, often unrelated to the interaction | Chunk/yield elsewhere in the app |
| Processing | The handler does too much | Acknowledge visually now, defer the rest past a task boundary |
| Presentation | Huge DOM, expensive style recalc, forced sync layout, large paint | Containment, content-visibility, fewer invalidated elements, compositor-friendly properties |
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" |
| Update the rendering | The fixed spec sequence: 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 − 50ms) over long tasks |
| 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 |
| 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 |
scheduler.yield() | Yields with continuation priority — resumes ahead of newly-arrived low-priority work |
scheduler.postTask() | Explicit user-blocking/user-visible/background priorities with AbortSignal |
| Structured clone | Serialisation across a worker boundary; synchronous main-thread cost, proportional to payload |
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.
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.
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.
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.
6. 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. -
"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.
7. Interview talking points
- "INP decomposes into input delay, processing, and presentation, and those are three different
bugs with three different fixes. 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 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." - "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." - "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."
8. 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.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.