Prioritized Task Scheduling API
Note: This feature is available inWeb Workers.
ThePrioritized Task Scheduling API provides a standardized way to prioritize all tasks belonging to an application, whether they are defined in a website developer's code or in third-party libraries and frameworks.
Thetask priorities are very coarse-grained and based around whether tasks block user interaction or otherwise impact the user experience, or can run in the background. Developers and frameworks may implement more fine-grained prioritization schemes within the broad categories defined by the API.
The API is promise-based and supports the ability to set and change task priorities, to delay tasks being added to the scheduler, to abort tasks, and to monitor for priority change and abort events.
Concepts and usage
The Prioritized Task Scheduling API is available in both window and worker threads using thescheduler
property on the global object.
The main API methods arescheduler.postTask()
andscheduler.yield()
.scheduler.postTask()
takes a callback function (the task) and returns a promise that resolves with the return value of the function or rejects with an error.scheduler.yield()
turns anyasync
function into a task by yielding the main thread to the browser for other work, with execution continuing when the returned promise is resolved.
The two methods have similar functionality but different levels of control.scheduler.postTask()
is more configurable — for example, it allows task priority to be explicitly set and task cancellation via anAbortSignal
.scheduler.yield()
on the other hand is simpler and can beawait
ed in anyasync
function without having to provide a followup task in another function.
scheduler.yield()
To break up long-running JavaScript tasks so they don't block the main thread, insert ascheduler.yield()
call to temporarily yield the main thread back to the browser, which creates a task to continue execution where it left off.
async function slowTask() { firstHalfOfWork(); await scheduler.yield(); secondHalfOfWork();}
scheduler.yield()
returns a promise that can be awaited to continue execution. This allows work belonging to the same function to be included there, without blocking the main thread when the function runs.
scheduler.yield()
takes no arguments. The task that triggers its continuation has a defaultuser-visible
priority; however, ifscheduler.yield()
is called within ascheduler.postTask()
callback, it willinherit the priority of the surrounding task.
scheduler.postTask()
Whenscheduler.postTask()
is called with no arguments, it creates a task with a defaultuser-visible
priority that cannot be aborted or have its priority changed.
const promise = scheduler.postTask(myTask);
Because the method returns a promise, you can wait on its resolution asynchronously usingthen()
, and catch errors thrown by the task callback function (or when the task is aborted) usingcatch
. The callback function can be any kind of function (below we demonstrate an arrow function).
scheduler .postTask(() => "Task executing") // Promise resolved: log task result when promise resolves .then((taskResult) => console.log(`${taskResult}`)) // Promise rejected: log AbortError or errors thrown by task .catch((error) => console.error(`Error: ${error}`));
The same task might be waited on usingawait
/async
as shown below (note, this is run in anImmediately Invoked Function Expression (IIFE)):
(async () => { try { const result = await scheduler.postTask(() => "Task executing"); console.log(result); } catch (error) { // Log AbortError or error thrown in task function console.error(`Error: ${error}`); }})();
You can also specify an options object to thepostTask()
method if you want to change the default behavior.The options are:
priority
This allows you to specify a particular immutable priority.Once set, the priority cannot be changed.signal
This allows you to specify a signal, which may be either aTaskSignal
orAbortSignal
The signal is associated with a controller, which can be used to abort the task.ATaskSignal
can also be used to set and change the task priority if thetask is mutable.delay
This allows you to specify the delay before the task is added for scheduling, in milliseconds.
The same example as above with a priority option would look like this:
scheduler .postTask(() => "Task executing", { priority: "user-blocking" }) .then((taskResult) => console.log(`${taskResult}`)) // Log the task result .catch((error) => console.error(`Error: ${error}`)); // Log any errors
Task priorities
Scheduled tasks are run in priority order, followed by the order that they were added to the scheduler queue.
There are just three priorities, which are listed below (ordered from highest to lowest):
user-blocking
Tasks that stop users from interacting with the page.This includes rendering the page to the point where it can be used, or responding to user input.
user-visible
Tasks that are visible to the user but not necessarily blocking user actions.This might include rendering non-essential parts of the page, such as non-essential images or animations.
This is the default priority for
scheduler.postTask()
andscheduler.yield()
.background
Tasks that are not time-critical.This might include log processing or initializing third party libraries that aren't required for rendering.
Mutable and immutable task priority
There are many use cases where the task priority never needs to change, while for others it does.For example fetching an image might change from abackground
task touser-visible
as a carousel is scrolled into the viewing area.
Task priorities can be set as static (immutable) or dynamic (modifiable) depending on the arguments passed toScheduler.postTask()
.
Task priority is immutable if a value is specified in theoptions.priority
argument.The given value will be used for the task priority and cannot be changed.
The priority is modifiable only if aTaskSignal
is passed to theoptions.signal
argumentandoptions.priority
isnot set.In this case the task will take its initial priority from thesignal
priority, and the priority can subsequently be changed by callingTaskController.setPriority()
on the controller associated with the signal.
If the priority is not set withoptions.priority
or by passing aTaskSignal
tooptions.signal
then it defaults touser-visible
(and is by definition immutable).
Note that a task that needs to be aborted must setoptions.signal
to eitherTaskSignal
orAbortSignal
.However for a task with an immutable priority,AbortSignal
more clearly indicates that the task priority cannot be changed using the signal.
Let's run through an example to demonstrate what we mean by this. When you have several tasks that are of roughly the same priority, it makes sense to break them down into separate functions to aid with maintenance, debugging, and many other reasons.
For example:
function main() { a(); b(); c(); d(); e();}
However, this kind of structure doesn't help with main thread blocking. Since all five of the tasks are being run inside one main function, the browser runs them all as a single task.
To handle this, we tend to run a function periodically to get the code toyield to the main thread. This means that our code is split into multiple tasks, between the execution of which the browser is given the opportunity to handle high-priority tasks such as updating the UI. A common pattern for this function usessetTimeout()
to postpone execution into a separate task:
function yield() { return new Promise((resolve) => { setTimeout(resolve, 0); });}
This can be used inside a task runner pattern like so, to yield to the main thread after each task has been run:
async function main() { // Create an array of functions to run const tasks = [a, b, c, d, e]; // Loop over the tasks while (tasks.length > 0) { // Shift the first task off the tasks array const task = tasks.shift(); // Run the task task(); // Yield to the main thread await yield(); }}
To improve this further, we can useScheduler.yield
when available to allow this code to continue executing ahead of other less critical tasks in the queue:
function yield() { // Use scheduler.yield if it exists: if ("scheduler" in window && "yield" in scheduler) { return scheduler.yield(); } // Fall back to setTimeout: return new Promise((resolve) => { setTimeout(resolve, 0); });}
Interfaces
Scheduler
Contains the
postTask()
andyield()
methods for adding prioritized tasks to be scheduled.An instance of this interface is available on theWindow
orWorkerGlobalScope
global objects (globalThis.scheduler
).TaskController
Supports both aborting a task and changing its priority.
TaskSignal
A signal object that allows you to abort a task and change its priority, if required, using a
TaskController
object.TaskPriorityChangeEvent
The interface for the
prioritychange
event, which is sent when the priority for a task is changed.
Note:If thetask priority never needs to be changed, you can use anAbortController
and its associatedAbortSignal
instead ofTaskController
andTaskSignal
.
Extensions to other interfaces
Window.scheduler
andWorkerGlobalScope.scheduler
These properties are the entry points for using the
Scheduler.postTask()
method in a window or a worker scope, respectively.
Examples
Note that the examples below usemyLog()
to write to a text area.The code for the log area and method is generally hidden to not distract from more relevant code.
<textarea></textarea>
#log { min-height: 20px; width: 95%;}
// hidden logger code - simplifies examplelet log = document.getElementById("log");function myLog(text) { log.textContent += `${text}\n`;}
Feature checking
Check whether prioritized task scheduling is supported by testing for thescheduler
property in the global scope.
The code below prints "Feature: Supported" if the API is supported on this browser.
<textarea></textarea>
#log { min-height: 20px; width: 95%;}
// hidden logger code - simplifies examplelet log = document.getElementById("log");function myLog(text) { log.textContent += `${text}\n`;}
// Check that feature is supportedif ("scheduler" in globalThis) { myLog("Feature: Supported");} else { myLog("Feature: NOT Supported");}
Basic usage
Tasks are posted usingScheduler.postTask()
, specifying a callback function (task) in the first argument, and an optional second argument that can be used to specify a task priority, signal, and/or delay.The method returns aPromise
that resolves with the return value of the callback function, or rejects with either an abort error or an error thrown in the function.
<textarea></textarea>
#log { min-height: 100px; width: 95%;}
let log = document.getElementById("log");function myLog(text) { log.textContent += `${text}\n`;}
Because it returns a promise,Scheduler.postTask()
can bechained with other promises.Below we show how to wait on the promise to resolve usingthen
.This uses the default priority (user-visible
).
// A function that defines a taskfunction myTask() { return "Task 1: user-visible";}if ("scheduler" in this) { // Post task with default priority: 'user-visible' (no other options) // When the task resolves, Promise.then() logs the result. scheduler.postTask(myTask).then((taskResult) => myLog(`${taskResult}`));}
The method can also be used withawait
inside anasync function.The code below shows how you might use this approach to wait on auser-blocking
task.
function myTask2() { return "Task 2: user-blocking";}async function runTask2() { const result = await scheduler.postTask(myTask2, { priority: "user-blocking", }); myLog(result); // Logs 'Task 2: user-blocking'.}runTask2();
In some cases you might not need to wait on completion at all.For simplicity many of the examples here simply log the result as the task executes.
// A function that defines a taskfunction myTask3() { myLog("Task 3: user-visible");}if ("scheduler" in this) { // Post task and log result when it runs scheduler.postTask(myTask3);}
The log below shows the output of the three tasks above.Note that the order they are run depends on the priority first, and then the declaration order.
Permanent priorities
Task priorities may be set usingpriority
parameter in the optional second argument.Priorities that are set in this way areimmutable (cannot be changed).
Below we post two groups of three tasks, each member in reverse order of priority.The final task has the default priority.When run, each task simply logs it's expected order (we're not waiting on the result because we don't need to in order to show execution order).
let log = document.getElementById("log");function myLog(text) { log.textContent += `${text}\n`;}
if ("scheduler" in this) { // three tasks, in reverse order of priority scheduler.postTask(() => myLog("bkg 1"), { priority: "background" }); scheduler.postTask(() => myLog("usr-vis 1"), { priority: "user-visible" }); scheduler.postTask(() => myLog("usr-blk 1"), { priority: "user-blocking" }); // three more tasks, in reverse order of priority scheduler.postTask(() => myLog("bkg 2"), { priority: "background" }); scheduler.postTask(() => myLog("usr-vis 2"), { priority: "user-visible" }); scheduler.postTask(() => myLog("usr-blk 2"), { priority: "user-blocking" }); // Task with default priority: user-visible scheduler.postTask(() => myLog("usr-vis 3 (default)"));}
<textarea></textarea>
#log { min-height: 120px; width: 95%;}
The output below shows that the tasks are executed in priority order, and then declaration order.
Changing task priorities
Task priorities can also take their initial value from aTaskSignal
passed topostTask()
in the optional second argument.If set in this way, the priority of the taskcan then be changed using the controller associated with the signal.
Note:Setting and changing task priorities using a signal only works when theoptions.priority
argument topostTask()
is not set, and when theoptions.signal
is aTaskSignal
(and not anAbortSignal
).
The code below first shows how to create aTaskController
, setting the initial priority of its signal touser-blocking
in theTaskController()
constructor.
The code then usesaddEventListener()
to add an event listener to the controller's signal (we could alternatively use theTaskSignal.onprioritychange
property to add an event handler).The event handler usespreviousPriority
on the event to get the original priority andTaskSignal.priority
on the event target to get the new/current priority.
The task is then posted, passing in the signal, and then we immediately change the priority tobackground
by callingTaskController.setPriority()
on the controller.
<textarea></textarea>
#log { min-height: 70px; width: 95%;}
let log = document.getElementById("log");function myLog(text) { log.textContent += `${text}\n`;}
if ("scheduler" in this) { // Create a TaskController, setting its signal priority to 'user-blocking' const controller = new TaskController({ priority: "user-blocking" }); // Listen for 'prioritychange' events on the controller's signal. controller.signal.addEventListener("prioritychange", (event) => { const previousPriority = event.previousPriority; const newPriority = event.target.priority; myLog(`Priority changed from ${previousPriority} to ${newPriority}.`); }); // Post task using the controller's signal. // The signal priority sets the initial priority of the task scheduler.postTask(() => myLog("Task 1"), { signal: controller.signal }); // Change the priority to 'background' using the controller controller.setPriority("background");}
The output below demonstrates that the priority was successfully changed tobackground
fromuser-blocking
.Note that in this case the priority is changed before the task is executed, but it could equally have been changed while the task was running.
Aborting tasks
Tasks can be aborted using eitherTaskController
andAbortController
, in exactly the same way.The only difference is that you must useTaskController
if you also want to set the task priority.
<textarea></textarea>
#log { min-height: 50px; width: 95%;}
let log = document.getElementById("log");function myLog(text) { log.textContent += `${text}\n`;}
The code below creates a controller and passes its signal to the task.The task is then immediately aborted.This causes the promise to be rejected with anAbortError
, which is caught in thecatch
block and logged.Note that we could also have listened for theabort
event fired on theTaskSignal
orAbortSignal
and logged the abort there.
if ("scheduler" in this) { // Declare a TaskController with default priority const abortTaskController = new TaskController(); // Post task passing the controller's signal scheduler .postTask(() => myLog("Task executing"), { signal: abortTaskController.signal, }) .then((taskResult) => myLog(`${taskResult}`)) // This won't run! .catch((error) => myLog(`Error: ${error}`)); // Log the error // Abort the task abortTaskController.abort();}
The log below shows the aborted task.
Delaying tasks
Tasks can be delayed by specifying an integer number of milliseconds in theoptions.delay
parameter topostTask()
.This effectively adds the task to the prioritized queue on a timeout, as might be created usingsetTimeout()
.Thedelay
is the minimum amount of time before the task is added to the scheduler; it may be longer.
<textarea></textarea>
#log { min-height: 50px; width: 95%;}
let log = document.getElementById("log");function myLog(text) { log.textContent += `${text}\n`;}
The code below shows two tasks added (as arrow functions) with a delay.
if ("scheduler" in this) { // Post task as arrow function with delay of 2 seconds scheduler .postTask(() => "Task delayed by 2000ms", { delay: 2000 }) .then((taskResult) => myLog(`${taskResult}`)); scheduler .postTask(() => "Next task should complete in about 2000ms", { delay: 1 }) .then((taskResult) => myLog(`${taskResult}`));}
Refresh the page.Note that the second string appears in log after about 2 seconds.
Specifications
Specification |
---|
Prioritized Task Scheduling # scheduler |
Early detection of input events # the-scheduling-interface |
Browser compatibility
api.Scheduler
api.Scheduling
See also
- Building a Faster Web Experience with the postTask Scheduler on the Airbnb blog (2021)
- Optimizing long tasks on web.dev (2022)