Concepts — Blink Scheduling
Phase 4 · Spec area §16. Prerequisites: bi-02, bi-10.
Hard prerequisite from the sibling track: fe-01 (execution model).
fe-01teaches 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:
| Primitive | Rendering opportunity? | Cost |
|---|---|---|
| microtask | No — measured 0 frames over 400 ms | ~free, and useless as a yield |
setTimeout | Yes, but clamped to 4 ms once nested ≥5 | caps at ~250 slices/sec |
MessageChannel | Yes, unclamped | what React's scheduler uses |
scheduler.yield() | Yes, continuation priority | same INP as MessageChannel, 3.4× the wall clock |
What you should be able to do after this module
| Capability | Evidence you have it |
|---|---|
| Explain a queue priority | Say what it protects and what it starves |
| Locate INP latency | Attribute delay to the queue and policy that produced it |
| Choose a yield primitive | Derive the choice from mechanism, not from a recommendation |
| Design a priority policy | Include 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
| Primitive | Yields to rendering? | Yields to input? |
|---|---|---|
queueMicrotask / await | no | no |
setTimeout(f, 0) | yes | yes, but low priority and clamped |
MessageChannel postMessage | yes | yes; historically the lowest-latency macrotask yield |
scheduler.postTask({priority}) | yes | yes, with explicit priority |
scheduler.yield() | yes | yes, and resumes with continuation priority |
requestIdleCallback | yes | runs 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
| Concern | Where |
|---|---|
| Main-thread scheduler | platform/scheduler/main_thread/ |
| Per-frame scheduling / throttling | frame_scheduler_impl.* |
| Agent group scheduling | agent_group_scheduler_impl.* |
| Idle estimation | idle_time_estimator.* |
| Task queue plumbing | platform/scheduler/base/ |
| Docs | platform/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.
ResizeObserverruns 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
| Primitive | Yields to render? | Yields to input? | Continuation priority | Notes |
|---|---|---|---|---|
queueMicrotask, await | no | no | n/a | not a yield at all |
setTimeout(f, 0) | yes | yes | low; clamped | 4 ms clamping after nesting depth 5 |
MessageChannel | yes | yes | back of the queue | historically the lowest-latency macrotask yield |
scheduler.postTask({priority}) | yes | yes | as specified | explicit user-blocking / user-visible / background |
scheduler.yield() | yes | yes | continuation — ahead of newly-arrived same-priority work | the point of the API |
requestIdleCallback | yes | yes | idle only | may 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
MessageChannelyielding, 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
- Priority inversion experiment. Post work at several
scheduler.postTaskpriorities plussetTimeoutandMessageChannel. Register a click handler. Measure ordering and input delay under a synthetic main-thread load. Predict the ordering first. - Yield-cost curve. Take a 200 ms computation. Chunk it yielding every 0.5/5/50 ms via
MessageChannel, then viascheduler.yield(). Plot total time and p75 input delay. Find where each curve turns. - rAF vs timer. Animate with both under load. Measure frame alignment and dropped frames.
- Continuation priority. Construct a case where
MessageChannelyielding loses to unrelated tasks andscheduler.yield()does not. This is the experiment that proves why the API exists. - 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
- Recursive
queueMicrotask— freeze the tab. Confirm the debugger cannot break in cleanly, and explain why in terms of the checkpoint. - Starve rendering with a chain of high-priority tasks. Find where anti-starvation kicks in.
- 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
- Perfetto with
toplevel+blink+cc: identify task boundaries, the microtask checkpoint, and the rendering opportunity in one trace. - Find a long task and attribute it to a task queue/source.
- Correlate a
TRACE_EVENTname from the trace back to its source (bi-01, rung 3 → rung 1). - 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
| Term | Definition |
|---|---|
| Task source | The spec's grouping of tasks; ordering is guaranteed within one only |
| Task queue | An implementation's queue fed by one or more sources |
| Scheduling policy | The rule selecting which queue to service; changes dynamically under load |
| Anti-starvation | Escalation ensuring low-priority work eventually runs |
| Microtask checkpoint | Drain point, reached when the JS execution context stack becomes empty |
| Rendering opportunity | A point where the browser may run "update the rendering" |
| BeginFrame | Vsync-driven signal starting frame production |
| Long task | ≥50 ms; poor attribution via longtask |
| LoAF | Long Animation Frames — blockingDuration, renderStart, per-script attribution |
| Input delay | Time from user action to handler start; a scheduling quantity |
scheduler.postTask | Explicit 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 policy | The scheduler's altered behaviour during page load |
| Off-main-thread scheduling | Worker, 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
-
"There's one task queue and it's FIFO." Many queues, implementation-chosen, with ordering guaranteed only within a source.
-
"
setTimeout(fn, 0)means zero." ≥1 ms, and 4 ms once nested five deep — capping asetTimeoutchunker at roughly 250 slices per second regardless of slice size. -
"Microtasks yield to the event loop." They do not create a rendering opportunity at all.
-
"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. -
"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.
-
"
longtasktells you what was slow." Attribution is famously poor. LoAF givesblockingDuration,renderStartand a per-script breakdown. -
"
isInputPending()is the smart way to chunk." It ignores rendering entirely. -
"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-rolledMessageChannelyield 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
-
A timer registered before a click handler runs after it. Explain, then say what this implies for code that assumes registration order.
-
scheduler.yield()resumes with continuation priority. Reconstruct the problem this solves from first principles, and describe the bug you would see without it. -
React's scheduler prefers
MessageChannel. Reconstruct that decision. What would break with microtasks, and withsetTimeout? -
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?
-
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?
-
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.
-
Throttling is per-frame. What does this enable that per-page throttling would not, and what abuse does it invite?
-
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? -
When is a long task the right engineering decision? Give a concrete case and the invariant yielding would violate.
-
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.