On this page
scheduler.yield() and Prioritized Task Scheduling
Learn how scheduler.yield() and the Prioritized Task Scheduling API improve main-thread responsiveness through priority-aware continuation scheduling, with specification algorithms, Chromium internals, and production guidance.
Reference Card
- Specification
- Prioritized Task Scheduling (WICG)
- Status
- Draft Community Group Report - not W3C Standards Track
- Last revision
- 30 May 2025
- Editor
- Scott Haseley (Google)
- Chrome / Edge 129+ (yield); 94+ (postTask)
- Firefox Shipped August 2025 (yield)
- Safari Not implemented (either method)
- HTML Standard, ECMAScript, DOM Standard, WebIDL
- Schedule a postTask task · Schedule a yield continuation
- Select the next scheduler task queue from all schedulers
- Continuation-state propagation (HostMakeJobCallback / HostCallJobCallback)
- Anti-starvation override confirmed only for Chromium (Blink)
- Cross-source interleaving behavior is implementation-defined by spec
- Official polyfill omits yield() priority inheritance
- Unawaited yield() inside non-awaiting iteration helpers (forEach)
- Holding a lock or resource across a yield point (priority inversion)
- Assuming polyfilled and native behavior are equivalent
Note. Prioritized Task Scheduling is a WICG Draft Community Group Report, not a W3C standard and not on the W3C Standards Track. The current spec revision referenced here is dated 30 May 2025.
scheduler.postTask()shipped in Chromium M94 (2021).scheduler.yield()shipped in Chromium M129 (September 2024) after an origin trial beginning in M115. Firefox shippedyield()in August 2025. Safari implements neither method. This is a living standard; verify current status against the WICG repository before relying on specifics below.
1. Introduction
JavaScript on the main thread runs to completion. Once a task starts, the browser cannot interrupt it to handle a click, a keystroke, or a frame update.
This is not an implementation detail. It is a language-level guarantee that makes synchronous code predictable.
The trade-off is responsiveness. A task that runs for more than 50 milliseconds is classified as a long task.
Long tasks block everything else: input handling, rendering, and any other script waiting its turn. They are the single largest contributor to poor Interaction to Next Paint (INP) scores.
The scale of the problem is documented, not assumed. A Chrome-on-Android trace analysis cited in the WICG explainer found that of interactions slower than 200ms, 18.76% had a JavaScript-heavy long task (over 100ms) blocking input.
Roughly 10% had a single event handler that itself ran for more than 100ms.
Developers have always been able to yield the main thread voluntarily - hand control back to the browser and resume later. The problem was never the ability to yield. It was the cost.
Existing primitives (setTimeout(0), postMessage(), MessageChannel) send a yielding task’s continuation to the back of an arbitrary queue, where unrelated same-priority work can run first. This makes yielding expensive precisely for the latency-sensitive code that most needs it, which discourages developers from doing it at all.
Prioritized Task Scheduling addresses this with two complementary primitives on a new Scheduler object, reachable via window.scheduler or self.scheduler in workers: postTask(), for scheduling a discrete unit of prioritized work, and yield(), for pausing and resuming within a task that is already running.
Both are covered here, with the primary focus on yield(), since it is the piece that most existing coverage explains incompletely.
Note. For a basic how-to, see MDN’s Prioritized Task Scheduling API documentation or web.dev’s “Optimize long tasks.” What follows assumes familiarity with the event loop, promises, and async/await, and focuses on what the specification actually guarantees, how one production engine implements it, and where that guarantee currently breaks down across browsers.
Note. Working knowledge of the HTML Event Loop, the Long Tasks API, and
AbortController/AbortSignalis assumed throughout. Dedicated Specification.website references for each are forthcoming; where a claim depends on one of them, that dependency is stated explicitly rather than re-explained inline.
Key takeaway: The API exists to make voluntary yielding cheap enough that developers actually do it, by giving a yielding task’s continuation a scheduling advantage it would not otherwise have.
2. Mental Model
Before any syntax, two concepts need to be fixed clearly, because getting them wrong undermines everything that follows.
A task is a scheduled unit of work - a JavaScript callback that runs asynchronously as its own event-loop task. A continuation is the resumption of a paused task in a new event-loop task, after it has voluntarily yielded.
Both are event-loop-level concepts. Neither is a JavaScript-syntax construct like a generator’s yield keyword.
The specification defines three priorities: user-blocking, user-visible (the default), and background. Each names an intent, not yet a mechanism - the mechanism is covered in Section 5.
The single most important reframing needed here: scheduler.yield() is not sched_yield().
POSIX’s sched_yield() lets the current thread step aside so that any equal-or-higher-priority ready thread can run, with no guarantee about what happens next. The Linux kernel community has repeatedly and publicly corrected the misconception that sched_yield() provides any forward-progress guarantee.
A widely quoted kernel comment states plainly that removing a sched_yield() call should never break correct code, because the scheduler was always free to run the calling task anyway.
The web’s scheduler.yield() is semantically different by design. Because JavaScript tasks cannot be preempted and a long task can block input indefinitely, the specification’s authors chose to let continuations outrank same-priority work rather than merely step aside for it.
This is a deliberate divergence from thread-yielding semantics on other platforms, made explicit in the WICG explainer.
Two parallel lanes: yielding via a generic primitive shows a task’s continuation queued behind unrelated same-priority work that arrived later. Yielding via scheduler.yield() shows the continuation inserted ahead of that same unrelated work, though still behind anything of genuinely higher priority.
Key takeaway: yield() doesn’t give the thread away unconditionally - it gives it away with an implicit claim on getting it back before other same-priority work does.
3. A Grounding Example
The following example is referenced throughout the sections that follow.
async function processQueue(items) {
for (const item of items) {
doWork(item);
await scheduler.yield();
}
}
Each iteration does a unit of work, then yields. The await is what actually pauses execution - scheduler.yield() only schedules the continuation; it does not pause anything by itself.
This distinction matters because of the most common way developers silently defeat this API:
// WRONG: forEach does not await the callback's returned promise.
items.forEach(async (item) => {
doWork(item);
await scheduler.yield(); // Does not pause the outer forEach loop.
});
Array.prototype.forEach does not wait for each callback’s promise to settle before moving to the next iteration. The await scheduler.yield() inside the callback pauses only that individual callback invocation - the loop itself races ahead synchronously, defeating the entire purpose while looking correct at a glance.
Key takeaway: scheduler.yield() schedules a continuation; only await (used inside a construct that actually respects it) pauses execution. Iteration helpers that don’t await their callbacks silently break this.
4. Vocabulary: Tasks, Microtasks, and Continuations
Before the algorithm walkthrough, three event-loop citizens need to be distinguished precisely, because conflating them is one of the most common errors in existing coverage of this API.
An HTML task runs in its own event-loop turn, associated with a task source and a task queue. A microtask runs immediately after the current task or microtask, before the event loop can move on - promise .then() callbacks are the canonical example. A yield() continuation is a full new HTML task, not a microtask.
This means a rendering opportunity, or another higher-priority task, can run between a yield() call and its continuation. Developers who expect yield() to behave like a microtask checkpoint - resuming immediately, with no other work interleaved - will be surprised.
| Property | HTML Task | Microtask | yield() Continuation |
|---|---|---|---|
| Queue | Event-loop task queue (per task source) | Microtask queue | Scheduler task queue |
| Runs relative to current work | Next event-loop turn | Immediately after current task, before the next task | Next event-loop turn (it is a task) |
| Can a render happen first? | Yes | No | Yes |
| Preemptable mid-execution | No | No | No |
Table 1 - Task, microtask, and scheduler continuation compared.
Two more terms recur throughout: a scheduler task queue is one of the internal queues the Scheduler object maintains, keyed by priority (and, for TaskSignal-driven tasks, by signal); effective priority is the numeric value the specification uses to rank those queues against each other, covered next.
Note. INP itself is measured via the Event Timing API, not by this specification - INP is treated only as motivation (Section 1), not as a mechanism this API implements directly.
Key takeaway: A yield() continuation is a task, not a microtask - the browser can and does interleave other work, including rendering, before it runs.
5. The Priority System, Mechanically
Section 2 introduced the idea that continuations get a scheduling “boost.” The exact mechanism follows.
5.1 The three priorities
user-blocking is for work that should run as soon as possible - chunked work in direct response to input, or updates to in-viewport UI state. user-visible, the default for both postTask() and yield(), is for work with observable but non-critical effects. background is for work that is not time-critical, such as logging or initializing non-essential third-party code.
Note.
backgroundpriority is deliberately close in spirit torequestIdleCallback()- the specification’s own patch to that older API (Section 4.2.1) sets exactly this priority as the ambient context when an idle callback runs, which is whyyield()called from inside one inheritsbackgroundby default (see Section 6 and theyield-priority-idle-callbacks.htmltest in Section 14).
5.2 Effective priority
The specification does not rank tasks only by their nominal TaskPriority. It defines an effective priority, computed from both the priority and whether the queue holds continuations:
| Priority | Is Continuation | Effective Priority |
|---|---|---|
background | false | 0 |
background | true | 1 |
user-visible | false | 2 |
user-visible | true | 3 |
user-blocking | false | 4 |
user-blocking | true | 5 |
Table 2 - Effective priority ladder (normative).
The effective-priority table shows one relationship immediately: a yield() continuation always outranks a freshly-posted postTask() task at the same nominal priority, but never outranks a task at a genuinely higher priority.
A background continuation (row 1) still loses to a fresh user-visible task (row 2). The boost is local to same-tier competition, not a blanket override of the whole priority system.
MDN describes the practical effect as yield() enqueuing its task at the front of a priority level’s queue, while postTask() appends at the back. That is a useful mental model, though the actual mechanism is the effective-priority table, not literal queue position.
A vertical stack of the six rows from Table 2, ordered top to bottom from highest effective priority (5, user-blocking continuation) to lowest (0, background task) - reading down the stack is reading down the effective-priority column exactly as Table 2 defines it.
5.3 Strict ordering, by specification
Tasks scheduled through a given Scheduler run in strict priority order - user-blocking always before user-visible, always before background. This is a normative guarantee within the scheduler’s own queues. It says nothing yet about how scheduler tasks interleave with other event-loop task sources (timers, postMessage, network callbacks) - that question is addressed in Section 6.5.
scheduler.postTask(() => console.log("background"), { priority: "background" });
scheduler.postTask(() => console.log("user-visible"), { priority: "user-visible" });
// A "user-visible" continuation from an enclosing postTask() would run
// ahead of a fresh "user-visible" postTask() task, per the effective-priority table.
The two postTask() calls above run in strict priority order regardless of call sequence - background last, user-visible first - exactly as Section 5.3’s guarantee requires.
Key takeaway: Same-named priority is not the same effective priority. A yield() continuation and a postTask() task can both be nominally user-visible and still not compete as equals.
6. Normative Algorithms - A Guided Walkthrough
Everything in this section is specification text, paraphrased into plain English. Section 7 covers what one real browser engine actually does; Section 12 covers the corresponding production guidance. Keeping these separate matters, because normative requirements, implementation choices, and operational advice answer different questions and should not be read as interchangeable.
6.1 Scheduling a postTask() task
The algorithm, in outline, builds a promise to return, then reads the signal option if present and rejects immediately if it is already aborted.
Next it constructs a scheduling state that records both an abort source and a priority source - from the explicit priority option, from a TaskSignal, or defaulting to user-visible.
If a delay is specified, the task waits before enqueuing; otherwise it enqueues immediately. When the task runs, the algorithm invokes the callback and resolves or rejects the returned promise with its result or thrown error.
The priority source, once established, is fixed for postTask() unless it derives from a TaskSignal whose controller later calls setPriority().
This is also where the specification’s split between static and dynamic priority queues originates.
A Scheduler maintains two internal maps: a static priority task queue map, for tasks whose priority is fixed at scheduling time (an explicit priority option, or a signal that is a plain AbortSignal rather than a TaskSignal); and a dynamic priority task queue map, keyed by TaskSignal, for tasks whose priority can change later via controller.setPriority().
Tasks associated with the same TaskSignal share a single dynamic queue, and that queue’s own priority moves in response to prioritychange events.
The specification notes this as a deliberate implementation choice. A logically equivalent design - one queue per TaskPriority, with tasks migrating between queues on a priority change - would simplify the next-task-selection algorithm at the cost of making priority changes themselves more complex.
TaskController and TaskSignal are what make dynamic priority possible. TaskController extends AbortController; its signal getter (inherited from AbortController) returns a TaskSignal rather than a plain AbortSignal, and it adds a setPriority() method.
TaskSignal extends AbortSignal, adding a read-only priority property, an onprioritychange event handler, and a static TaskSignal.any() factory - a specialization of AbortSignal.any() that also lets the resulting signal’s priority track a source TaskSignal.
const controller = new TaskController({
priority: "user-visible",
});
scheduler.postTask(longRunningWork, { signal: controller.signal });
// Later, in response to the task leaving the viewport:
controller.setPriority("background");
setPriority() does not create a new task or requeue anything explicitly - it moves the dynamic queue the task already belongs to, by firing a prioritychange event and re-evaluating that queue’s priority in place.
6.2 Scheduling a yield continuation
yield()’s algorithm is narrower. It reads the current scheduling state - ambient, inherited context, not anything passed as an argument - checks whether the inherited abort source is already aborted (rejecting immediately if so), selects the appropriate queue based on the inherited priority (or user-visible if there is none to inherit), and resolves the returned promise when that queued task runs.
The critical detail: this algorithm only reads the inherited state. It never writes to it. A yield() call cannot change what a later yield() call in the same task will inherit - this was an explicitly resolved open question in the WICG repository (issue #96, closed as #100).
6.3 Selecting the next task to run
Because a page can have multiple Scheduler objects - same-origin iframes, nested workers sharing an agent - the specification defines a pooling algorithm: “select the next scheduler task queue from all schedulers.”
It gathers the runnable queues from every Scheduler sharing the same event loop, discards any queue whose effective priority is lower than the maximum found, and returns the queue whose oldest runnable task has the smallest enqueue order.
Enqueue order is a strictly increasing counter maintained per event loop, not a timestamp, guaranteeing a total, race-free ordering among tasks scheduled at the same instant - at least in principle. Ordering guarantees under concurrent “in parallel” spec steps are one of the acknowledged gaps discussed in Section 6.5.
This algorithm is a fairness guarantee across multiple schedulers on the same page, not just within one - a subtlety most existing coverage omits entirely.
6.4 Priority and abort inheritance through the job-callback machinery
This is the deepest mechanism in the specification - the propagation logic that makes the scenario above work is the single piece of machinery most existing explanations of this API skip entirely, which is why it receives the most detailed diagram in this reference.
The following scenario, adapted from the WICG explainer, illustrates this:
async function task() {
doWork(); // Still inside the postTask() callback.
await scheduler.yield(); // Continuation resumes in a new task, same context.
const data = await fetch(url); // Hops through a network task.
processData(data); // Does this still know the original priority?
await scheduler.yield(); // What priority does this continuation get?
}
scheduler.postTask(task, { priority: "background" });
The answer the specification gives is: yes, priority survives the fetch() hop, and the final yield() still inherits background.
This works because the browser’s event loop carries a continuation state - an event-loop-scoped slot, not a call-stack-scoped one.
Three normative patches make this work.
First, queue a microtask is modified to clone the event loop’s current continuation state and reapply it around the microtask’s own execution, so state survives across microtask checkpoints.
Second, and most significantly, the ECMAScript host hooks HostMakeJobCallback and HostCallJobCallback - which govern how .then() callbacks are created and invoked - are patched to snapshot the continuation state at the moment a job callback (such as a .then() handler) is created, and restore it at the moment that callback is actually called.
Third, HostEnqueuePromiseJob is adjusted so that ordinary promise-resolution jobs queue with ignoreContinuationState set, preventing unrelated promise machinery from leaking scheduler context where it shouldn’t.
The diagram traces continuation state being snapshotted into the job callback created by await fetch(url), carried across the network task boundary, and restored when that callback runs - showing concretely why the final yield() in the example above still resolves with background priority.
The design authors considered three alternatives for how far this propagation should extend.
One option: require fully manual re-propagation at every step. A second: stop propagating the moment scheduler context is lost, such as after the first fetch(). A third: propagate through the entire async task regardless of intervening hops.
They rejected the second option as brittle - an intermediate await fetch() would silently change yielding behavior. They chose the third: propagation depends only on how the task was originally scheduled, not on what happens inside it.
Key takeaway: Inheritance is not lexical. It is carried through the ECMAScript job/microtask machinery itself, which is why a yield() after an intervening await fetch() still inherits the priority the enclosing task was scheduled with.
6.5 What the specification leaves undefined, on purpose - and what it leaves genuinely unresolved
These are not the same thing, and the specification is explicit about the difference.
Deliberately implementation-defined: the choice of which queue to run next - one of the ordinary HTML event-loop task queues, or the scheduler’s own pooled queue - is explicitly left implementation-defined in the HTML patch (Section 4.1.3 of the spec).
This mirrors how HTML already leaves task-source-to-task-queue mapping implementation-defined, and is a conscious trade-off favoring UA scheduling flexibility over cross-browser interleaving guarantees.
The spec offers non-binding guidance - elevate effective priority 3+ above most task sources but below input and rendering; run priority 0–1 only when nothing else is runnable; treat priority 2 like the timer task source - but does not require it.
Acknowledged, unresolved gaps: the specification’s own inline issue markers flag that the “next enqueue order” counter and scheduler-task-queue mutations, reachable from spec-parallel steps, need atomic-update guarantees not yet fully worked out. This is cross-referenced to a broader unresolved HTML issue about task-queue race conditions.
Separately, run steps after a timeout (used for postTask()’s delay option) does not necessarily account for page or tab suspension correctly - an HTML-level gap this specification inherits rather than fixes.
| Category | Example | What it means for implementers |
|---|---|---|
| Normative | Effective priority table; strict priority ordering within a Scheduler | Must be implemented exactly as specified |
| Normative, but calls out UA flexibility | HTML event-loop patch’s task-queue selection step | Cross-browser interleaving behavior may legitimately differ |
| Informative | Security and Privacy Considerations sections | Non-binding threat-model discussion, not conformance requirements |
| Acknowledged open gap | Enqueue-order atomicity under parallel steps; timeout/suspension interaction | Correctness edge case, not yet resolved by any spec revision |
Table 3 - Normative, implementation-defined, informative, and open-gap classification.
Key takeaway: The specification is precise about ordering within a scheduler’s own queues, and deliberately vague about how those queues compete with everything else on the page - that gap is where most of the real cross-browser behavioral risk lives.
7. Inside the Browser: How Chromium Implements This
The previous sections described normative specification behavior. This section describes one engine’s implementation - Chromium’s - labeled explicitly as implementation detail, not a normative requirement any other browser must replicate.
Chromium’s architecture, per Blink’s own scheduler module documentation, layers four classes.
DOMScheduler is per-document, created lazily on first access to window.scheduler, and observes document lifecycle changes so it can stop running tasks when its context is destroyed.
DOMScheduler owns a set of DOMTaskQueue objects, one per priority, each of which wraps a WebSchedulingTaskQueue - the interface into Blink’s general-purpose scheduler.
WebSchedulingTaskQueue objects are created through the document’s FrameScheduler and are parameterized by a WebSchedulingPriority, which Blink maps directly onto a base::sequence_manager::TaskQueue::QueuePriority - the same underlying primitive used for ordinary Blink task scheduling throughout the renderer.
A DOMTask wraps the callback, its arguments, its eventual result, and a TaskHandle used for cancellation.
DOMScheduler owns three DOMTaskQueue instances, one per priority, each wrapping a WebSchedulingTaskQueue created via the document’s FrameScheduler. That queue exposes a TaskRunner which posts directly onto Blink’s base::sequence_manager::TaskQueue, where the anti-starvation override described below actually takes effect.
One implementation detail is worth calling out specifically because it demonstrates spec conformance requiring an engine-level carve-out.
Blink’s general-purpose scheduler has a built-in anti-starvation mechanism that occasionally lets lower-priority work run ahead of higher-priority work, to prevent indefinite starvation elsewhere in the browser. The scheduling API’s implementation explicitly disables this mechanism for its own priority levels, in order to achieve the strict, static priority ordering the specification requires.
Without this override, Chromium’s own general-purpose fairness heuristics would violate the spec’s own priority guarantee.
Note. The class architecture above -
DOMScheduler,DOMTaskQueue,WebSchedulingTaskQueue,DOMTask, and the anti-starvation override - is documented in Blink’s own module README and is confirmed. The exact source files and specific classes implementingyield()’s continuation queues specifically, as opposed topostTask()’s task queues, were not independently verified to file-and-line granularity in the research behind this reference. Readers who need that level of detail should consult Chromium Code Search directly rather than take file names asserted elsewhere at face value.
Key takeaway: Chromium routes scheduler priorities onto the same low-level task-queue priority mechanism used throughout the renderer, but had to explicitly suppress a pre-existing fairness heuristic to make the specification’s strict-ordering guarantee hold.
8. Cross-Browser Reality
Support tables answer “does it exist.” This section answers “does it behave the same way” - a materially different, and mostly unanswered, question.
8.1 Timeline and formal positions
scheduler.postTask() shipped in Chromium M94 (2021). scheduler.yield() ran as an origin trial from Chromium M115 (August 2023) and shipped unflagged in M129 (September 2024).
Firefox shipped yield() in August 2025, roughly a year after Chromium. Safari implements neither postTask() nor yield().
At the time Chromium filed its Intent to Ship for postTask(), both Gecko and WebKit’s formal standards-positions signal was “No signal” - neither opposed nor committed.
WebKit’s tracked position issue for the whole Prioritized Task Scheduling proposal remains open, and WebKit’s only substantive public engagement has been a naming critique (the scheduler.render() inconsistency, addressed in Section 15), not a review of yield()’s core semantics.
Chromium’s own reasoning for shipping despite this was explicit in its Intent-to-Ship thread: the API is polyfillable, which was treated as sufficient mitigation for the adoption risk of shipping without a formal cross-vendor commitment.
8.2 The polyfill is not behaviorally equivalent
scheduler-polyfill, the officially recommended fallback, emulates postTask() and yield() using existing browser primitives. It explicitly does not implement yield()’s priority inheritance.
This means “supported via polyfill” and “natively supported” are observably different runtimes, not just different support statuses - code that relies on inherited priority surviving a yield() call will behave correctly on native Chromium and Firefox and differently under the polyfill.
8.3 What’s confirmed versus what isn’t
| Chromium | Firefox | Safari | |
|---|---|---|---|
Ships postTask() | Yes (M94, 2021) | Unclear from MDN legacy data; adoption lagged | No |
Ships yield() | Yes (M129, 2024) | Yes (August 2025) | No |
| Anti-starvation override confirmed | Yes (Blink README) | Not independently confirmed | N/A |
| Interleaving-with-other-task-sources behavior confirmed identical to Chromium | N/A (reference) | Not independently confirmed | N/A |
| Formal standards position | Shipped despite “No signal” from others | “No signal” at Chromium’s Intent-to-Ship stage | Open tracking issue, no committed position |
Table 4 - Browser support and behavioral confidence matrix.
Warning. Web Platform Tests passing on a given browser confirms that engine’s priority ordering within the scheduler’s own queues - the contract Section 6.1–6.3 describe. It does not confirm that scheduler tasks interleave with timers,
postMessage, or network callbacks the same way across browsers, because the specification leaves that interleaving implementation-defined by design (Section 6.5). Firefox’s internal interleaving behavior specifically was not independently verified for this reference.
Key takeaway: Real production use of this API today means: Chromium and Firefox for native yield() behavior including priority inheritance, no Safari support at all, and a polyfill fallback that changes observable behavior rather than merely filling a gap.
9. Edge Cases and Failure Modes
9.1 The non-awaited yield, formalized
Covered in Section 3: Array.prototype.forEach does not await its callback’s returned promise, so a yield() inside a forEach callback pauses only that callback invocation, not the surrounding loop. Any iteration helper with the same non-awaiting behavior has the identical failure mode.
9.2 Inherited state cannot be mutated mid-task
A yield() call reads ambient scheduling state but never writes it - resolved explicitly in WICG issue #96/#100. Passing different priority behavior to one yield() call does not change what a later yield() call in the same async function inherits.
9.3 TaskSignal.any() and garbage collection
TaskSignal objects created via TaskSignal.any() can be “dependent” on a source signal via a weak reference. The specification requires that a dependent TaskSignal with a non-null source signal must not be garbage-collected while it still has registered prioritychange listeners or non-empty priority-change algorithms - an easy-to-miss retention rule for anyone building abstractions on top of TaskSignal.
9.4 Re-entrant priority changes throw
Calling controller.setPriority() while a prioritychange event for that same signal is still dispatching throws a NotAllowedError. This is a re-entrancy guard, not a bug.
9.5 Detached documents reject cleanly
yield() called on a Scheduler whose document is detached returns an already-rejected promise. BFCache is handled correctly - tasks pause while a page is in the back-forward cache and resume if the page is restored - but a genuinely disconnected document stops running tasks entirely, and any promises left unresolved at that point stay unresolved.
9.6 Priority inversion through held resources
This is the one failure mode the specification acknowledges but explicitly declines to solve at the platform level.
A low-priority task that acquires a resource - a Web Lock, for instance - and then yields, can block a higher-priority task that needs the same resource, for as long as the low-priority task’s continuation is delayed.
The specification’s own non-normative note on this (in misc/priority-inversion.md) states plainly that developers are responsible for not holding resources across yield points. There is no platform-level priority-inheritance protocol, unlike the classic real-time-systems literature this note itself cites.
// Anti-pattern: holding a lock across a yield point risks priority inversion.
await navigator.locks.request("shared-resource", async (lock) => {
doPartialWork();
await scheduler.yield(); // A higher-priority task waiting on this lock is now blocked.
doMoreWork();
});
A low-priority task holding a lock, yielding; a higher-priority task attempting to acquire the same lock blocks behind the not-yet-resumed low-priority continuation - effectively inverting the two tasks’ relative priority for as long as the lock is held.
The specification’s own note frames this as a consequence of a more general observation: userspace tasks that span multiple browser task sources are hard to reason about as having a single “priority” at all, because scheduler.postTask() only assigns a priority to a callback, not to an entire yieldy asynchronous operation that may hop through timers, network requests, and other task sources along the way.
Real-time operating systems solve the analogous problem with priority-inheritance protocols, where a low-priority task temporarily inherits the priority of whatever higher-priority task it is blocking. The web platform has no equivalent mechanism here, and the specification does not commit to building one - it is named explicitly as a known, unresolved limitation rather than an oversight.
9.7 A note on postTask() cancellation timing
A related but distinct edge case: if a postTask() call includes both a delay and a signal, and that signal aborts during the delay window, the task is never enqueued at all - the abort check happens at the point the delay expires, not only at the moment postTask() was originally called. Code that assumes a delayed, signal-bound task will always at least attempt to run once its delay passes should account for this.
A consolidated symptom-to-fix reference for all six edge cases above appears in Section 14.3, alongside the diagnostic tooling needed to confirm each one.
Key takeaway: Most scheduler.yield() failures are not scheduling bugs - they are ordinary async-JavaScript mistakes (unawaited promises, held resources) that this API makes easier to introduce because its syntax looks deceptively simple.
10. Security and Privacy Considerations
The specification’s own Security Considerations section is narrow and specific, covering only two concerns rather than generic side-channel advice.
10.1 Not a high-resolution timer
postTask()’s delay option is expressed in whole milliseconds with a 1ms minimum non-zero value, and tasks are not guaranteed to run the instant their delay expires. The specification states plainly that this precision ceiling is intentional, ruling the API out as a usable high-resolution timing primitive.
10.2 Cross-origin task-timing inference
The substantive concern: a single OS thread can run tasks from only one event loop at a time.
An attacker running on one origin can potentially learn something about another origin’s task activity on a shared thread by flooding the system with its own prioritized tasks or continuations and observing gaps - inferring that something else ran during those gaps, possibly including tasks belonging to a different origin’s event loop.
This is a variant of a documented attack class; the specification cites prior academic work on postMessage()-based timing side channels (Vila and Köpf, USENIX Security 2017) as established precedent, not a novel risk this API introduces from nothing.
Two origins’ event loops are time-sliced onto one shared OS thread. The attacker floods its own event loop with tasks at a known priority and measures the timing between them; an unexpected gap suggests the thread was given to something else, including possibly the victim origin, during that interval. The mitigations in Section 10.3 all target the shared-thread precondition this diagram depicts.
The specification frames the risk in terms of the set of potential tasks a UA might choose to run instead of an attacker’s flooded tasks - that set corresponds directly to how much information the attacker can extract.
A purely static, UA-wide prioritization scheme (input always highest, network always second, and so on) makes that set small and predictable, and using user-blocking postTask() tasks or elevated-priority yield() continuations could narrow it further, since this API makes priority explicit where implementations previously had to infer it from implicit task-source ordering.
A UA that instead uses a more dynamic scheme - occasionally running lower-priority task sources depending on how long they have been starved - widens that set and correspondingly lowers the fidelity of any inference an attacker can draw.
10.3 Suggested mitigations
The specification suggests, non-normatively: isolating cross-origin event loops onto separate OS threads where possible (removing the shared-thread precondition entirely); using inter-event-loop scheduling that is not strictly priority-based, such as round-robin or fair scheduling, to reduce the information gained from timing gaps; and periodically cycling in lower-priority tasks to prevent starvation patterns from leaking priority information.
10.4 Privacy
The specification’s Privacy Considerations section is a single conclusion: the authors evaluated the API and found no privacy considerations. This is the authors’ own self-review, not an independent audit finding, and should be read as a starting position rather than a closing one.
Key takeaway: The real security surface here is cross-origin timing inference on a shared thread, a known attack class the specification inherits rather than introduces, with UA-level mitigations offered as suggestions rather than requirements.
11. Performance: What’s Measured Versus What’s Assumed
The mechanical performance argument is solid and spec-grounded: the effective-priority table in Section 5.2 guarantees that a yield() continuation will be scheduled ahead of same-priority fresh work. That is a real, verifiable ordering guarantee.
What does not currently exist, in any authoritative or vendor-published source located during research for this reference, is a rigorous, reproducible benchmark comparing wall-clock latency across scheduler.yield(), setTimeout(0), MessageChannel, requestIdleCallback(), and scheduler.postTask(). Comparisons in circulation are anecdotal blog-level tests, not peer-reviewed or vendor-authoritative measurements. Repeating those numbers as settled fact would manufacture a false sense of precision, so they are omitted rather than cited.
What is well-supported is that yielding has real, non-trivial per-call overhead - the cost of posting a task and regaining control of the thread. Chrome’s own guidance is explicit: batch work and yield on an elapsed-time or isInputPending()-based threshold, rather than yielding after every small unit of work, because at high call frequency the overhead of yielding can exceed the time saved.
// Naive: yields after every single item - overhead-heavy at scale.
for (const item of items) {
process(item);
await scheduler.yield();
}
// Better: yield only after a time budget is exhausted.
let deadline = performance.now() + 5; // 5ms budget per chunk.
for (const item of items) {
process(item);
if (performance.now() >= deadline) {
await scheduler.yield();
deadline = performance.now() + 5;
}
}
The second version yields the same total number of times as there are 5ms budget windows, not once per item - the overhead scales with elapsed time, not with loop iteration count.
Chromium tracks a SchedulerYield UseCounter, referenced in its Intent-to-Ship thread, which is a real signal of production adoption scale. It measures usage frequency across the web, not comparative latency, and should not be read as benchmark evidence.
| Primitive | Continuation queue position | Priority control | Cancellation support | Cross-browser support |
|---|---|---|---|---|
setTimeout(0) | Back of an arbitrary queue; subject to nested-call delay clamping | None | None (only via clearTimeout before it fires) | Universal |
postMessage() / MessageChannel | Back of the message-port queue | None | None | Universal |
requestIdleCallback() | Runs only during idle periods | Implicitly lowest priority | Via cancelIdleCallback | No Safari support |
scheduler.postTask() | End of its priority’s queue | Full (three explicit tiers, dynamic via TaskSignal) | Via AbortSignal/TaskSignal | Chromium, Firefox; no Safari |
scheduler.yield() | Boosted position within its inherited priority’s queue | Inherited only, no explicit override | Via inherited AbortSignal | Chromium, Firefox; no Safari |
Table 5 - Yielding primitive comparison.
Warning. No authoritative benchmark exists, as of this writing. Treat any specific millisecond figures encountered for this comparison with appropriate skepticism unless they come with a reproducible methodology.
Key takeaway: The spec-guaranteed ordering advantage of scheduler.yield() is real and mechanical; the empirical performance-benchmarking field for this API is thin, and claims beyond the mechanical guarantee should be treated as unverified until better data exists.
12. Production Guidance
This section is operational advice, deliberately separated from the architecture and algorithm content above.
12.1 Feature detection
if (globalThis.scheduler?.yield) {
// Native support available.
}
Check globalThis, not just window - the API is available in Web Workers as well, and globalThis covers both scopes without branching.
12.2 Choose a fallback deliberately
Three options exist, and the choice should be explicit rather than defaulted into.
scheduler-polyfill is the most convenient path but does not implement yield() priority inheritance (Section 8.2) - acceptable if your code does not depend on inherited priority surviving a yield.
A manual setTimeout-based fallback is simpler still but loses the prioritized-continuation advantage entirely, meaning your yielding code performs no better than pre-2023 techniques on unsupported browsers.
Declining to yield at all on unsupported browsers is only acceptable if the underlying long-task problem is not severe enough to require it there.
function yieldToMain() {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
This helper makes the fallback explicit at the call site rather than hidden inside a bundler polyfill, which makes it easier to grep for and reason about later.
12.3 Batch, don’t yield per-iteration
Covered mechanically in Section 11 - repeated here as a standing production rule, since it is the single most common performance mistake in real yielding code.
12.4 Never hold a resource across a yield point
Directly follows from the priority-inversion edge case in Section 9.6. If code needs a lock or similar exclusive resource, acquire it, do the minimum necessary synchronous work, release it, and only then yield - do not yield while still holding it.
12.5 TypeScript support
The wicg-task-scheduling npm types package provides type definitions for projects doing manual feature detection, useful since the API is not yet part of the default TypeScript DOM library definitions on all target configurations.
Production checklist
- Feature-detect on
globalThis.scheduler?.yield, not justwindow.- Pick a fallback deliberately - polyfill (no priority inheritance), manual setTimeout (no boosted continuation), or no yielding - don’t default into one by accident.
- Yield on a time or input-pending threshold, not on every loop iteration.
- Never hold a lock or exclusive resource across a
yield()call.
Key takeaway: This API is safe to adopt today only with an explicit, tested fallback path - treating it as universally available, or assuming polyfilled and native behavior are equivalent, are the two most common production mistakes.
13. Who’s Actually Using This
React’s Scheduler package - the internal scheduler behind React’s concurrent rendering - does not use scheduler.postTask() or scheduler.yield().
Its source shows a fallback chain instead: setImmediate where available (Node.js, legacy IE), otherwise MessageChannel (preferred in browsers specifically because it avoids setTimeout’s nested-call delay clamping), and setTimeout only as a last resort.
React also calls navigator.scheduling.isInputPending() directly to decide when to yield mid-render, independent of the native Scheduler API.
The most likely explanation, inferred rather than confirmed by any React team statement located in research, is reach: React’s scheduler needs to work in Node.js and in every supported browser, not only in Chromium and Firefox, so depending on a Chromium-originated API for core scheduling would narrow React’s own compatibility surface.
Real adoption of scheduler.postTask()/yield() appears concentrated one layer up - in application code and in small wrapper libraries (react-use-scheduler, main-thread-scheduling) that adopt the native API with a polyfill fallback, rather than inside major framework internals.
// Illustrative of React's documented fallback pattern - not verbatim source.
let schedule;
if (typeof setImmediate === "function") {
schedule = (cb) => setImmediate(cb);
} else if (typeof MessageChannel !== "undefined") {
const channel = new MessageChannel();
channel.port1.onmessage = cb;
schedule = () => channel.port2.postMessage(null);
} else {
schedule = (cb) => setTimeout(cb, 0);
}
The pattern in application-layer wrapper libraries is consistent: bind native scheduling to a component’s lifecycle or visibility, and demote priority automatically when relevance drops. One published example hook pairs a postTask function with a ref; when the bound element leaves the viewport, in-flight and future task priorities for that component are downgraded to background, and restored if it re-enters view. This pattern - visibility-driven priority, layered on top of the native API rather than reimplementing scheduling from scratch - is a reasonable template for how the API is likely to see the most real-world use in the near term: as an enhancement inside already-async application code, not as a replacement for a framework’s own internal scheduler.
Claims about Angular, Vue, Next.js, Chrome DevTools, or Google Docs internally using this API were not independently verified during research for this reference and are deliberately omitted rather than asserted without a source.
Key takeaway: The framework most likely to benefit from native prioritized scheduling - React - does not use it internally, most plausibly because its own cross-environment reach exceeds this API’s current cross-browser reach.
14. Diagnostics
Chrome DevTools’ Performance panel remains the primary manual-inspection tool: record a trace, and long tasks appear as gray bars with a red flag in the corner once they exceed 50ms.
Dedicated visualization of scheduler.yield() continuations specifically was stated only as an intention by the Chrome team at the API’s origin-trial stage (“basic new-API DevTools support,” with plans to explore Performance-panel integration “in some way”). Current shipped status of any such dedicated visualization was not independently reconfirmed for this reference - verify against current DevTools documentation rather than assuming a specific capability exists.
The Long Tasks API (PerformanceObserver with entryTypes: ['longtask']) is the field-measurement equivalent, useful for real-user-monitoring pipelines that can’t rely on manual DevTools inspection.
For testing on older Chromium builds before the feature shipped unflagged, chrome://flags/#enable-experimental-web-platform-features, or the command-line flag --enable-blink-features=SchedulerYield, enable it explicitly.
14.1 Verifying priority inheritance in DevTools
There is no dedicated DevTools panel that labels a task’s inherited TaskPriority directly. The practical workaround is indirect, using relative ordering as a proxy for priority.
Record a Performance trace while deliberately racing a background-priority continuation against a user-visible one scheduled at roughly the same time. In the trace’s Main thread track, confirm the user-visible work executes first - if it doesn’t, inheritance did not propagate the way the code assumed.
// Minimal reproduction: confirms whether priority survived a fetch() hop.
performance.mark("start");
scheduler.postTask(async () => {
await scheduler.yield();
await fetch("/ping"); // Deliberately hop through a network task.
await scheduler.yield();
performance.mark("background-continuation-ran");
performance.measure("inherited-priority-check", "start", "background-continuation-ran");
}, { priority: "background" });
scheduler.postTask(() => {
performance.mark("user-visible-ran");
}, { priority: "user-visible" });
If user-visible-ran consistently appears before background-continuation-ran in the resulting trace or performance.getEntriesByName() output, priority inheritance held across the fetch() boundary as Section 6.4 describes. If ordering is inconsistent, that is a signal to check for one of the failure modes in Section 9 rather than assume the browser is misbehaving.
14.2 Writing a minimal reproducible test
For bug reports or cross-browser verification, isolate the behavior in question to a single .html file with no framework or bundler involved - the pattern the WPT test files themselves follow. A minimal repro should: feature-detect scheduler.yield, schedule two or three tasks whose relative order is unambiguous under the effective-priority table, log or mark their execution order, and assert on that order rather than on wall-clock timing (which varies across machines and load conditions).
14.3 Common symptoms, causes, and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Continuation never runs | Document detached before the continuation could resolve (Section 9.5) | Guard against detachment, or catch and ignore the rejection if the work is no longer relevant |
| Continuation runs, but seemingly out of order | Iteration helper (e.g. forEach) not awaiting the yielding callback (Section 9.1) | Replace with a for...of loop or for loop that awaits each iteration explicitly |
| Continuation runs late despite high nominal priority | Held lock or resource blocking progress - priority inversion (Section 9.6) | Release locks before yielding; restructure to acquire, work, release, then yield |
| Priority changes silently ignored | setPriority() called while a prioritychange event for the same signal was already dispatching (Section 9.4) | Defer the second setPriority() call until after the current dispatch completes, e.g. via a microtask |
yield() behaves like user-visible when background was expected | Called outside any postTask()/requestIdleCallback() context - there is no ambient priority to inherit | Wrap the yielding code in scheduler.postTask() with the intended priority, or accept the user-visible default |
| Behavior differs between browsers in the same test | Testing against the polyfill on one browser and native support on another (Section 8.2) | Confirm feature-detection results per browser before comparing behavior |
Expanded troubleshooting reference.
Web Platform Tests provide the authoritative ground truth for expected behavior, beyond any single article’s description of it:
| Test file | Behavior verified |
|---|---|
yield-abort.any.js | Promise rejection when the inherited or associated signal aborts |
yield-inherit-across-promises.any.js | Priority inheritance surviving an intervening await fetch() |
yield-priority-idle-callbacks.html | yield() inside requestIdleCallback() inherits background priority |
yield-priority-posttask.any.js | Priority inheritance from an enclosing postTask() callback |
yield-priority-timers.any.js | Default user-visible behavior when yielding outside any scheduler context |
yield-then-detach.html | Rejection behavior when the owning document is detached |
Table 6 - WPT test coverage map (scheduler/tentative/yield/).
Key takeaway: Chrome DevTools gives you a manual, visual first pass; a minimal race-based reproduction gives you a reliable yes/no answer on priority inheritance; the WPT suite gives you the authoritative behavioral contract if you need to verify a specific edge case against ground truth.
15. Open Questions and Where the Spec Is Headed
Several design tensions remain genuinely unresolved, and a living-standard API like this one should be read with that instability in mind.
Naming. WebKit’s standards-position review flagged that a planned future API, scheduler.render(), is inconsistent with the noun/verb pattern of .wait() and .yield() - it doesn’t actually render anything, and its meaning relative to the rendering lifecycle is ambiguous. This is tracked as WICG issue #95 and remains open; current thinking leans toward folding it into yield() as a parameter rather than a separate method, but this is not settled.
Event-loop integration. Issue #67 captures the tension discussed in Section 6.5: the specification currently leaves interleaving between scheduler tasks and other event-loop task sources fully implementation-defined, for maximum UA flexibility. There is live discussion about whether a denylist-based ordering guarantee - specifying relative order against a small, explicit set of task sources - should be formalized instead, trading some UA flexibility for stronger cross-browser guarantees.
Priority inversion. As covered in Section 9.6, this is acknowledged but deliberately left as a developer responsibility rather than a platform-level fix, with no committed timeline for revisiting that stance.
Inherited spec-level gaps. The enqueue-order atomicity question and the timeout/suspension interaction (Section 6.5) trace back to broader, still-open issues in the HTML specification itself, not to anything specific to this proposal - resolving them depends on HTML-level work outside this spec’s own scope.
Future work, loosely signaled. The explainer mentions scheduler.wait() (a zero-delay-wait-based alternative yielding semantic), exposing the current task’s TaskSignal via something like scheduler.currentTaskSignal, and extending priority propagation to fetch() and other async platform APIs. None of these are committed; they are directional signals from the same design document, not roadmap items.
Note. Open issues get resolved, superseded, or abandoned over time. Check the WICG scheduling-apis repository’s live issue tracker for current status rather than treating the summary above as permanent.
Key takeaway: The core mechanics covered in Sections 5 and 6 are stable and shipped; the naming, event-loop-integration guarantees, and priority-inversion handling remain open design questions with no committed resolution.
16. Conclusion
scheduler.yield() is not a web-flavored sched_yield(). It is a purpose-built mechanism for a run-to-completion execution model, designed specifically to make voluntary yielding cheap by letting a task’s own continuation outrank same-priority competitors - never by preempting genuinely higher-priority work, and never by guaranteeing forward progress the way some developers instinctively expect from anything called “yield.”
That guarantee is real and mechanically grounded in the effective-priority table. What surrounds it is less settled: empirical performance data is thin, Safari has no implementation at all, and the specification itself leaves significant interleaving behavior implementation-defined by design. Production adoption today should be deliberate - feature-detected, fallback-aware, and clear-eyed about the difference between polyfilled and native behavior - rather than assumed universal.
More broadly, Prioritized Task Scheduling is notable for a reason beyond its own mechanics. It is one of the first browser APIs to expose scheduling decisions directly to page authors, rather than leaving prioritization entirely implicit in how the engine happens to order task sources.
Understanding it well requires thinking past JavaScript syntax and into the browser’s event loop, its scheduling model, and the trade-offs its implementers accepted.
Key takeaways
yield()boosts a continuation’s effective priority relative to same-priority work; it does not create a total ordering over the event loop, and it is notsched_yield().- Priority and abort inheritance survive intervening
awaits - including network requests - because it is implemented through the ECMAScript job-callback machinery, not lexical scope.- Chromium and Firefox ship native
yield()with priority inheritance; Safari ships neither method; the official polyfill does not replicate inheritance behavior.- The most common real-world failures are ordinary async mistakes - unawaited promises in iteration helpers, resources held across yield points - not scheduler bugs.
- Treat performance claims beyond the spec’s mechanical ordering guarantee with skepticism until better benchmark data exists.
17. References
Primary (normative)
- Prioritized Task Scheduling (WICG Draft CG Report) - https://wicg.github.io/scheduling-apis/
- HTML Standard (event loop, task sources, job callback hooks) - https://html.spec.whatwg.org/multipage/webappapis.html
- DOM Standard - https://dom.spec.whatwg.org/
- WebIDL Standard - https://webidl.spec.whatwg.org/
requestIdleCallback()(W3C) - https://w3c.github.io/requestidlecallback/
Repository, explainers, and design discussion
- WICG/scheduling-apis repository - https://github.com/WICG/scheduling-apis
yield-and-continuation.mdexplainer - https://github.com/WICG/scheduling-apis/blob/main/explainers/yield-and-continuation.mdmisc/priority-inversion.md- https://github.com/WICG/scheduling-apis/blob/main/misc/priority-inversion.md- Open issues: #67 (event-loop integration), #95 (naming), #96/#100 (inherited-state semantics)
Browser process and standards positions
- Chrome Platform Status - https://chromestatus.com/feature/6266249336586240
- Blink
modules/schedulerREADME - Chromium source - WebKit standards-positions #361 - https://github.com/WebKit/standards-positions/issues/361
- W3C TAG design reviews #827 and #967
Practical and compatibility references
- MDN: Prioritized Task Scheduling API,
Scheduler.yield(),Window.scheduler - web.dev: “Optimize long tasks”
- Chrome for Developers blog: “Use scheduler.yield() to break up long tasks”
- Web Platform Tests:
scheduler/tentative/yield/
From the team at
We build digital products and explore the modern web standards behind them.