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 (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.

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?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
Correct usechunking work, yielding to the userbatching 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 primitiverAF frames renderedwall clock
await Promise.resolve()0401 ms
MessageChannel postMessage48403 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 termReal causeFix class
Input delayLong task already running, often unrelated to the interactionChunk/yield elsewhere in the app
ProcessingThe handler does too muchAcknowledge visually now, defer the rest past a task boundary
PresentationHuge DOM, expensive style recalc, forced sync layout, large paintContainment, content-visibility, fewer invalidated elements, compositor-friendly properties

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"
Update the renderingThe fixed spec sequence: 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 − 50ms) over long tasks
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
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
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 cloneSerialisation 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.

  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.

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

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

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

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

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, and delay across 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."
  • "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."
  • "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."

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 (s5 vs s6) 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.