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.


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.

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.