Concepts — Blink Scheduling

Phase 4 · Spec area §16. Prerequisites: bi-02, bi-10. Hard prerequisite from the sibling track: fe-01 (execution model).

fe-01 teaches the JS-observable event loop — what the HTML spec guarantees. This module teaches the implementation: how Chromium chooses what to run. Read them in that order. Reading this first means memorising a scheduler you cannot observe.


0. Orientation

The 60-second version

This is the module fe-01 measures from outside. Here you see the mechanism: why each queue priority exists, how the policy changes under load, and what the browser is protecting.

The central fact is that there is no single task queue. The spec says an event loop has multiple task queues fed by task sources, and "choose a task queue" is left to the implementation. Chromium implements that choice as a priority scheme with policies that change dynamically — during loading, during input, during compositor pressure — plus anti-starvation logic so the bottom of the priority order still runs.

That last part is the design lesson:

Priority systems degrade to uniformity unless something enforces fairness. Chromium does not trust priorities to be assigned honestly, because in a system with many contributors they never are. It builds escalation in.

Where ordering guarantees actually exist

Order is preserved within a task source, and only within one. Two setTimeouts run in order. A setTimeout and a postMessage have no defined relative order — which is the real root of most "works locally, flaky in CI" async tests.

The yielding primitives, priced

fe-01 measured what this module explains:

PrimitiveRendering opportunity?Cost
microtaskNo — measured 0 frames over 400 ms~free, and useless as a yield
setTimeoutYes, but clamped to 4 ms once nested ≥5caps at ~250 slices/sec
MessageChannelYes, unclampedwhat React's scheduler uses
scheduler.yield()Yes, continuation prioritysame INP as MessageChannel, 3.4× the wall clock

What you should be able to do after this module

CapabilityEvidence you have it
Explain a queue prioritySay what it protects and what it starves
Locate INP latencyAttribute delay to the queue and policy that produced it
Choose a yield primitiveDerive the choice from mechanism, not from a recommendation
Design a priority policyInclude the escalation rule, because priorities alone starve

1. Why a Principal Engineer needs this

1. "There is one task queue" is false, and everything downstream of that belief is wrong. The HTML spec says an event loop has multiple task queues and the implementation chooses. Chromium prioritises aggressively and changes policy dynamically. If your model is a single FIFO, you cannot explain why a timer registered before a click runs after it.

2. It explains why INP is not "make JS faster." Input delay is a scheduling term. The platform's mitigations — input prioritisation, anti-starvation, scheduler.postTask, scheduler.yield — are all interventions in this machinery.

3. It is where framework schedulers meet the platform. React's scheduler yields via MessageChannel for reasons that are only visible from this layer. Evaluating any framework's concurrency claims requires knowing what the browser actually promises.


2. Mental Model

2.1 Many queues, dynamic policy

Blink's main-thread scheduler maintains multiple prioritised task queues and selects among them. Priorities are not fixed — policy changes with page state (loading, gesture in progress, hidden, etc.). Roughly:

input  >  compositor  >  default  >  low / best-effort / idle

with anti-starvation guarantees so that a flood of high-priority work cannot indefinitely block lower-priority queues. Both halves matter: prioritisation without anti-starvation produces a browser where a busy animation permanently starves timers.

Per-frame scheduling exists too (frame_scheduler_impl), which is how a background iframe can be throttled independently of the foreground document. Throttling is per-frame, not per-page — a fact that explains a lot of third-party-iframe behaviour.

2.2 The rendering opportunity

Rendering is not a task you can post. The browser decides when a rendering opportunity occurs — normally aligned to vsync and to the compositor's BeginFrame. Within it:

run rAF callbacks -> ResizeObserver -> IntersectionObserver bookkeeping
-> style -> layout -> pre-paint -> paint -> commit

Two consequences:

  • rAF is not a timer. It is "just before the browser does its rendering work," which is why it is the right place to write animation state and the wrong place to do heavy computation.
  • A frame can be skipped entirely if nothing needs updating or if the compositor is not asking for one. setTimeout(f, 16) is not equivalent to rAF, and never will be.

2.3 Microtasks are not a yield point

A microtask checkpoint drains the whole queue, including microtasks enqueued during the drain. So await Promise.resolve() does not yield to rendering or to input. This is the single most consequential asymmetry in the platform (fe-01 covers it from the JS side); from the implementation side, the reason is simply that the checkpoint is inside the task, before the scheduler regains control.

2.4 Where the yielding primitives fit

PrimitiveYields to rendering?Yields to input?
queueMicrotask / awaitnono
setTimeout(f, 0)yesyes, but low priority and clamped
MessageChannel postMessageyesyes; historically the lowest-latency macrotask yield
scheduler.postTask({priority})yesyes, with explicit priority
scheduler.yield()yesyes, and resumes with continuation priority
requestIdleCallbackyesruns only in spare time

scheduler.yield()'s distinguishing property is that the continuation is scheduled ahead of newly-arrived same-priority work — which is precisely the problem MessageChannel yielding has: yield too often and your own continuation goes to the back of the queue behind unrelated tasks. Understanding that is what lets you evaluate a framework scheduler rather than trust its README.

2.5 Input, specifically

Input arrives on the compositor first. If the compositor can handle it (a scroll with no blocking listener), it never touches the main thread. Otherwise it is forwarded and queued at input priority. Input delay is time spent waiting for the main thread to finish something else — which is why a single long task, not slow JS in aggregate, is the usual culprit.


3. Under the Hood

ConcernWhere
Main-thread schedulerplatform/scheduler/main_thread/
Per-frame scheduling / throttlingframe_scheduler_impl.*
Agent group schedulingagent_group_scheduler_impl.*
Idle estimationidle_time_estimator.*
Task queue plumbingplatform/scheduler/base/
Docsplatform/scheduler/README.md, TaskSchedulingInBlink.md

TaskSchedulingInBlink.md is the right entry point — read it before code. Note the size of frame_scheduler_impl_unittest.cc relative to its implementation: when a unit test is more than twice the size of the code it tests, that is a subsystem where behaviour is subtle and regressions are expensive. Reading those tests is the fastest way to learn the intended policy.


3.5 Deep dive: the five scheduling policies, with their real numbers

platform/scheduler/TaskSchedulingInBlink.md names five distinct policies. Most engineers know "background tabs are throttled" and stop there; the reality is more differentiated, and the differences are observable.

1. Priorities

"The scheduler selects the next task to run based on the priority (modulo some starvation logic). The tasks with the same priority run in order."

The stated rules:

  • Input task runner has the highest priority.
  • Compositor task runner has high priority when user gestures are observed — not always, but during gestures.
  • Several ongoing experiments adjust priorities for individual frames.
  • Default priority is normal.

Two things to take from this. First, "modulo some starvation logic" is doing real work — strict priority without anti-starvation produces a browser where a busy animation permanently blocks timers. Second, the compositor's priority is conditional on gesture state, which is a dynamic policy: the same task is more urgent while the user's finger is down.

2. Pausing — ScopedPagePauser

During synchronous dialogs (alert(), print()) or inside V8 debugger breakpoints, the scheduler enters a nested run loop and stops running pauseable tasks. No JavaScript runs while a page is paused.

This is the mechanism behind a behaviour every web developer has met without understanding: alert() freezes everything, including timers and animations, in that page. It is also why debugging with breakpoints changes timing so drastically — you are not merely slow, you are in a different scheduling regime.

3. Deferring — the two-second window

"Scheduler defers tasks for two seconds after a user gesture, as it's very likely that another gesture will arrive soon. The majority of tasks can be deferred."

This is a genuinely surprising number and worth holding. After a tap or click, low-importance work is pushed out for two seconds on the bet that more interaction is coming. It is a latency optimisation that costs throughput, and it explains benchmark results that look wrong: work started right after an interaction may not run when you expect.

4. Freezing — five minutes on mobile

"On mobile all pages are frozen after five minutes in the background. On desktop only eligible pages are frozen, which is determined by heuristics based on the APIs the page is using."

Freezing stops task execution entirely, to save power and keep foreground tabs responsive. The platform-conditional behaviour is the important part: your background page behaves differently on Android than on desktop, by design, and "it works on my laptop" is not evidence about phones.

The API-usage heuristics on desktop are why a page holding a WebSocket or playing audio is treated differently from an idle one.

5. Throttling — narrower than you think

"At the moment only JavaScript timers (setTimeout/setInterval) are throttleable. While there is a general desire to expand this list, this is a low priority effort as we are focused on making freezing better instead."

This corrects a widespread misconception. Background throttling is not a general slowdown of everything — it is specifically timers. And the doc is candid that expanding it is deprioritised in favour of freezing, which is a strictly stronger tool.

Why quote the numbers. "Background tabs are throttled" is folklore. "Timers only; pages freeze after five minutes on mobile; unrelated work is deferred two seconds after a gesture" is a model you can predict with. The gap between those two states is exactly the gap this track exists to close.


3.6 Deep dive: task types, task sources, and why ordering surprises you

The HTML specification says an event loop has multiple task queues, and the implementation chooses which to service. Blink materialises this as task types mapped to task runners, so a timer callback, a DOM event, a network callback, and a postMessage are on different queues with potentially different priorities.

The practical consequence, restated precisely:

setTimeout(f, 0) registered before a click does not reliably run before the click handler, because they are in different task sources and input wins.

This is not a bug and it is not nondeterminism — it is a documented priority policy. Code that depends on cross-source ordering is depending on something no specification promises.

Per-frame scheduling (frame_scheduler_impl) means these policies apply per frame, not per page: a background cross-origin iframe can be throttled while the main document is not. That is why third-party iframe behaviour is often inexplicable from the top document's perspective.


3.7 Deep dive: the rendering opportunity, in order

Rendering is not a task you can post. The browser decides when a rendering opportunity occurs, normally aligned to vsync and the compositor's BeginFrame. Within it, roughly:

1. run requestAnimationFrame callbacks
2. run ResizeObserver callbacks   (may loop: mutations re-run layout, bounded by a depth limit)
3. IntersectionObserver bookkeeping
4. style recalculation
5. layout
6. pre-paint (invalidation + property trees)
7. paint
8. commit to the compositor

Consequences that explain real behaviour:

  • rAF runs before style and layout. That is why it is the right place to write animation state and the wrong place to do heavy work — anything expensive there directly delays the frame.
  • ResizeObserver runs after layout and may cause another layout. It is deliberately allowed to iterate to a fixed point, with a depth limit and a console error when exceeded.
  • A frame can be skipped entirely if nothing needs updating. setTimeout(f, 16) is not equivalent to rAF and never will be — one is a timer, the other is a position in the rendering steps.

3.8 Deep dive: the yielding primitives, compared honestly

PrimitiveYields to render?Yields to input?Continuation priorityNotes
queueMicrotask, awaitnonon/anot a yield at all
setTimeout(f, 0)yesyeslow; clamped4 ms clamping after nesting depth 5
MessageChannelyesyesback of the queuehistorically the lowest-latency macrotask yield
scheduler.postTask({priority})yesyesas specifiedexplicit user-blocking / user-visible / background
scheduler.yield()yesyescontinuation — ahead of newly-arrived same-priority workthe point of the API
requestIdleCallbackyesyesidle onlymay never run under sustained load

The distinguishing property of scheduler.yield() is worth stating plainly, because it is the whole reason the API exists:

With MessageChannel yielding, your continuation goes to the back of the queue, behind any unrelated task that arrived while you were working. Yield often enough and your own work starves. scheduler.yield() resumes with continuation priority, ahead of newly-arrived same-priority work.

This is why the naive advice "just yield more often" has a cost curve that turns upward, and why fw-04's lab measures it rather than asserting it.

The setTimeout clamp is the other detail worth knowing: after a nesting depth of five, timers are clamped to a minimum of 4 ms. Chunking with setTimeout(f, 0) therefore caps you at ~250 chunks/second regardless of how fast each chunk is.


3.9 Deep dive: input, INP, and where the latency actually is

Input arrives at the compositor first. If it can be handled there (a scroll with no blocking listener), the main thread is never involved. Otherwise it is forwarded and queued at input priority.

Interaction to Next Paint decomposes into three terms:

INP  =  input delay  +  processing time  +  presentation delay
        (waiting for      (your handler)    (style, layout, paint,
         the main thread)                    commit, raster, present)

Two of the three terms are not your handler. The most common real-world profile is a large input delay caused by one long task that was already running — so the fix is not optimising the handler at all, it is not having the long task.

That is why bi-11 and fe-01 insist on the same discipline from opposite directions: the sibling track teaches you to measure the decomposition, this module teaches you why the scheduler produces it.

Long Animation Frames (LoAF) is the API that attributes this: it reports frames that took too long along with blockingDuration and a scripts[] breakdown, which is how you find which script caused the long frame rather than knowing only that one existed.


3.10 Deep dive: off-main-thread scheduling

The doc's advice is blunt: if your task does not have to run on the main thread, do not put it there. Blink offers thread-pool task runners for exactly this.

The application-level analogue is the Web Worker decision, and the trade is the same in both places: you pay serialisation to buy parallelism. For a worker, postMessage performs a structured clone unless you transfer or share. So:

  • large data + trivial computation → the clone dominates; the worker loses,
  • modest data + heavy computation → the worker wins,
  • shared-memory approaches avoid the copy but require crossOriginIsolated (bi-02), which is a deployment decision, not just a code change.

Finding the crossover empirically is fe-01's lab; understanding why the crossover exists is this module.


4. Anti-Patterns

Chunking with microtasks. Does nothing for responsiveness; can freeze the tab.

setTimeout(f, 0) as a yield in a hot loop. Clamping and low priority make it slower than you expect; also it competes with unrelated tasks.

Doing heavy work in rAF. rAF runs before rendering — work there directly delays the frame.

Assuming timers fire on time in background tabs. Throttling is per-frame and aggressive.

Benchmarking scheduling on an unthrottled desktop. Priority effects appear under load.


5. Trade-offs

Prioritisation vs fairness. Aggressive input priority improves responsiveness and risks starvation; the anti-starvation machinery is the cost of that choice.

Dynamic policy vs predictability. Policy that changes with page state gives better real- world behaviour and makes reasoning (and reproducing bugs) harder.

Exposing scheduling to authors. scheduler.postTask gives authors real control and real ability to make things worse. Compare with the browser keeping it internal.


6. Lab

  1. Priority inversion experiment. Post work at several scheduler.postTask priorities plus setTimeout and MessageChannel. Register a click handler. Measure ordering and input delay under a synthetic main-thread load. Predict the ordering first.
  2. Yield-cost curve. Take a 200 ms computation. Chunk it yielding every 0.5/5/50 ms via MessageChannel, then via scheduler.yield(). Plot total time and p75 input delay. Find where each curve turns.
  3. rAF vs timer. Animate with both under load. Measure frame alignment and dropped frames.
  4. Continuation priority. Construct a case where MessageChannel yielding loses to unrelated tasks and scheduler.yield() does not. This is the experiment that proves why the API exists.
  5. Throttling. Measure timer behaviour in a hidden tab and a hidden cross-origin iframe.

Deliverable: the two plots from (2) and (4), plus a recommendation you would give a platform team about which yielding primitive to standardise on — including its failure mode.


7. Failure Lab

  1. Recursive queueMicrotask — freeze the tab. Confirm the debugger cannot break in cleanly, and explain why in terms of the checkpoint.
  2. Starve rendering with a chain of high-priority tasks. Find where anti-starvation kicks in.
  3. Write a long task that delays a click by >300 ms. Reduce it to <50 ms without making the total work faster. State exactly what you changed.

8. Debugging Exercise

  1. Perfetto with toplevel + blink + cc: identify task boundaries, the microtask checkpoint, and the rendering opportunity in one trace.
  2. Find a long task and attribute it to a task queue/source.
  3. Correlate a TRACE_EVENT name from the trace back to its source (bi-01, rung 3 → rung 1).
  4. Observe a frame that produced no rendering. Explain why not.

9. References

  • WHATWG HTML §Event loops, especially "update the rendering" and "perform a microtask checkpoint" — normative.
  • platform/scheduler/README.md, TaskSchedulingInBlink.md.
  • W3C Prioritized Task Scheduling (scheduler.postTask, scheduler.yield).
  • Long Animation Frames API; Event Timing.
  • React packages/scheduler/ — read after the lab, not before.

Core terminology

TermDefinition
Task sourceThe spec's grouping of tasks; ordering is guaranteed within one only
Task queueAn implementation's queue fed by one or more sources
Scheduling policyThe rule selecting which queue to service; changes dynamically under load
Anti-starvationEscalation ensuring low-priority work eventually runs
Microtask checkpointDrain point, reached when the JS execution context stack becomes empty
Rendering opportunityA point where the browser may run "update the rendering"
BeginFrameVsync-driven signal starting frame production
Long task≥50 ms; poor attribution via longtask
LoAFLong Animation Frames — blockingDuration, renderStart, per-script attribution
Input delayTime from user action to handler start; a scheduling quantity
scheduler.postTaskExplicit user-blocking/user-visible/background with AbortSignal
scheduler.yield()Yields with continuation priority — ahead of new low-priority work, behind rendering
isInputPending()Reports queued input; a trap for chunking, because rendering is not input
Timer clamping≥1 ms, and 4 ms once nested ≥5 deep
Loading policyThe scheduler's altered behaviour during page load
Off-main-thread schedulingWorker, raster and compositor task management

Mental models

There is no global FIFO, and ordering guarantees are per-source. This single correction removes a whole class of wrong reasoning about async behaviour — and it is why async tests that depend on the relative order of two different sources are flaky by construction rather than by accident.

Policy is dynamic, which is why microbenchmarks mislead. The scheduler behaves differently during load, during input, and under compositor pressure. A measurement taken in one regime does not transfer to another, which is why fe-01's benchmark throttles CPU and drives real input rather than timing a function.

Priorities without escalation starve the bottom. Chromium builds anti-starvation in because it cannot trust priorities to be honest. The organisational translation is exact: if you expose priorities to teams, everything drifts to the highest one, so design the escalation rule before you ship the priorities (fw-04).

Microtasks are not a yield; they are a hole. The queue must be drained to empty before the loop may reach the rendering steps, so a self-enqueueing chain never exits. fe-01 measured 0 frames rendered over 400 ms against 48 for the identical loop yielding via MessageChannel.

Responsiveness is bought with throughput, and the platform primitive is not free. scheduler.yield() and a hand-rolled MessageChannel yield produced identical INP (32 ms) at 3.4× the wall clock, because continuations are scheduled behind rendering. Correct default, real cost — and a live counterexample to "always prefer the platform primitive."

isInputPending() optimises the wrong thing. It yields only when input is waiting, and rendering is not input — so a loop using it can be click-responsive and visually frozen for seconds. A time-slice budget is insensitive to whether anyone happens to be clicking.


Common misconceptions

  1. "There's one task queue and it's FIFO." Many queues, implementation-chosen, with ordering guaranteed only within a source.

  2. "setTimeout(fn, 0) means zero." ≥1 ms, and 4 ms once nested five deep — capping a setTimeout chunker at roughly 250 slices per second regardless of slice size.

  3. "Microtasks yield to the event loop." They do not create a rendering opportunity at all.

  4. "Platform scheduling primitives are always the right choice." scheduler.yield() cost 3.4× the wall clock for identical INP. It is the right default and it is not free.

  5. "Priorities solve contention." They redistribute it and starve the bottom. Chromium adds anti-starvation precisely because it does not trust the priorities it is given.

  6. "longtask tells you what was slow." Attribution is famously poor. LoAF gives blockingDuration, renderStart and a per-script breakdown.

  7. "isInputPending() is the smart way to chunk." It ignores rendering entirely.

  8. "The scheduler behaves consistently." Policy changes under load, which is exactly when you care.


Interview talking points

  • "There is no single task queue. The spec says an event loop has many, fed by task sources, and choosing between them is left to the implementation — with ordering guaranteed only within a source. That's the real root of most 'works locally, flaky in CI' async tests: they depend on the relative order of two different sources, which was never guaranteed."
  • "Chromium's scheduler has anti-starvation logic because it doesn't trust priorities to be assigned honestly, and in a system with many contributors they never are. The organisational translation is exact — if you expose priorities to teams, everything drifts to the highest one, so you design the escalation rule before you ship the priorities."
  • "Microtasks aren't a yield, they're a hole. The queue has to drain to empty before the loop can reach the rendering steps. We measured zero frames rendered over 400 milliseconds of microtask 'chunking' versus 48 for the identical loop using MessageChannel."
  • "scheduler.yield() is the right default and it isn't free — we measured identical INP against a hand-rolled MessageChannel yield at 3.4 times the wall clock, because its continuations schedule behind rendering. That's a live counterexample to 'always prefer the platform primitive,' and I like having one."
  • "isInputPending() looks like the sophisticated way to chunk and it optimises the wrong thing. Rendering isn't input, so a loop yielding only for pending input can be responsive to clicks and visually frozen for seconds. A time-slice budget doesn't care whether anyone happens to be clicking."

10. Principal Engineer Review

  1. A timer registered before a click handler runs after it. Explain, then say what this implies for code that assumes registration order.

  2. scheduler.yield() resumes with continuation priority. Reconstruct the problem this solves from first principles, and describe the bug you would see without it.

  3. React's scheduler prefers MessageChannel. Reconstruct that decision. What would break with microtasks, and with setTimeout?

  4. Argue that exposing task priorities to authors was a mistake. Then argue it was necessary. What governance would you put around it in a large org?

  5. Anti-starvation guarantees make the scheduler harder to reason about. Argue for a strict priority scheduler with no anti-starvation. What is the first thing that breaks?

  6. Your INP is 400 ms; total JS execution per interaction is 40 ms. Enumerate where the other 360 ms could be, and how to confirm each.

  7. Throttling is per-frame. What does this enable that per-page throttling would not, and what abuse does it invite?

  8. Design a scheduling API for a framework that must work in browsers with and without scheduler.yield(). What does your fallback give up, and how would you make that visible?

  9. When is a long task the right engineering decision? Give a concrete case and the invariant yielding would violate.

  10. You are asked to set an org-wide rule about yielding. Write it in three sentences, including the exception.


Connections to other modules

  • bi-02 — the threads and processes this scheduler arbitrates between.
  • bi-10 — frame production, BeginFrame, and what competes with it.
  • bi-09 — where in the frame the rendering steps sit.
  • fe-01 — the same scheduler measured from outside: 0 frames from microtask chunking, 98× from layout thrashing, identical INP at 3.4× wall clock, 376× from a worker boundary.
  • fw-04 — you build a scheduler answering to these constraints, including the priority and starvation questions.
  • fw-03 — the microtask checkpoint used as a batching boundary rather than a yield.
  • fe-12 — concurrent rendering as the application-level consequence.