Topic: Web platform
Browser Scheduler: Classify Work Before You Queue It
Scheduler gives the browser a priority hint; it is not a background thread. Keep short UI work synchronous, then choose postTask, yield, or a Web Worker from measured evidence.
Animated meme (expand/collapse)
The name scheduler invites a misleading thought: “finally, I can take over the browser’s scheduler.” You cannot.
It is an API for describing a task’s importance to the browser. The browser still has input, animation, painting, networking, and other tasks to handle; your code can only say that something is more urgent or can wait. That boundary matters: Scheduler is not another thread, and it cannot make badly partitioned work fast by itself.
My starting point is deliberately conservative: complete short, immediately visible UI work synchronously; only schedule after measuring a main-thread long-task problem.
First, understand what it schedules
JavaScript on a page usually runs to completion on the main thread. Until synchronous code returns, the browser cannot slip in the next input handler or paint a frame. That is the usual reason a button was clicked but appears broken.
The Prioritized Task Scheduling API has three small, useful priorities:
| Priority | Good fit | It does not mean |
|---|---|---|
user-blocking |
Search results that must react to the text just entered | It will run before every other browser task |
user-visible |
An update people will soon see but that does not block their current action | It can wait forever |
background |
Telemetry, non-critical setup, and offscreen maintenance | It has moved to a background thread |
An unspecified scheduler.postTask() defaults to user-visible. The specification intentionally lets user agents decide how these tasks interleave with other event-loop work, so treat priority as a clear semantic hint—not a promise you can use for precise ordering.
postTask() queues new work; yield() pauses and resumes work
The APIs look related, but their roles differ:
scheduler.postTask(callback, options)gives the browser an independent callback to run later.await scheduler.yield()lets the currentasyncfunction return the main thread, then continue from the same point later.
Changing a search query is a good postTask() example. A newer input cancels the old work, so a slower old result cannot overwrite the current one:
let activeSearch;
function scheduleSearch(query) {
activeSearch?.abort();
const controller = new AbortController();
activeSearch = controller;
const render = () => {
if (controller.signal.aborted) return;
const matches = products.filter((product) => product.name.includes(query));
if (!controller.signal.aborted) renderResults(matches);
};
if (typeof globalThis.scheduler?.postTask === "function") {
void globalThis.scheduler.postTask(render, {
priority: "user-blocking",
signal: controller.signal,
}).catch((error) => {
if (error.name !== "AbortError") console.error(error);
});
} else {
setTimeout(render, 0);
}
}
The fallback does not reproduce Scheduler priority semantics. It only keeps search correct and prevents stale results from rendering. AbortController also cannot force-stop a synchronous loop that has already begun; it is most reliable for work that has not started yet, or APIs that accept a signal, such as fetch().
Animated meme (expand/collapse)
Use yield() only for work that can be split
yield() suits work that must remain on the main thread but can proceed in pieces, such as processing data already in memory. It gives the browser a chance to handle input and paint before the next piece runs.
Do not yield once per item. Devices differ and so does the cost of each record; a short time budget is a more stable boundary. This example yields when a slice nears 8ms and keeps a functional fallback for unsupported browsers:
function yieldToBrowser() {
if (typeof globalThis.scheduler?.yield === "function") {
return globalThis.scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function filterProducts(products, query) {
const matches = [];
let sliceStartedAt = performance.now();
for (const product of products) {
if (product.name.includes(query)) matches.push(product);
if (performance.now() - sliceStartedAt >= 8) {
await yieldToBrowser();
sliceStartedAt = performance.now();
}
}
return matches;
}
Eight milliseconds is an example, not a universal threshold. Yielding turns one task into several and adds control-flow complexity. Confirm a long task in the browser Performance panel before changing the partitioning.
When the work needs another thread, use a Worker
Scheduler still schedules main-thread work. Image compression, large-file parsing, long encryption, or seconds of CPU computation can continue to harm interaction even when chunked. Use a Web Worker instead.
A Worker cannot manipulate the DOM directly and must exchange data through messages or transfers. In return, its computation leaves the main thread. That is more honest than giving an expensive algorithm user-blocking priority.
| Work | Prefer |
|---|---|
| Disabling a button or showing a spinner | Run synchronously |
| An independent update directly tied to current input | postTask() with user-blocking |
| Splittable work that must stay on the main thread | yield() |
| Non-critical logging and maintenance | postTask() with background |
| Large CPU computations | Web Worker |
Treat Scheduler as a measured tool
Before shipping, I look for three things: faster visual feedback after input, fewer long tasks in a Performance trace, and better INP. More priority names in source code—or fewer timeouts—are not evidence of a better user experience.
The best Scheduler use is unglamorous: keep short interactions simple, explicitly defer work that can wait, and move heavy work away when the main thread cannot carry it. Classify the work first, so scheduling does not become another opaque layer of async state.
External references
- MDN: Scheduler
- MDN: Prioritized Task Scheduling API
- MDN:
scheduler.postTask() - MDN:
scheduler.yield() - WICG: Prioritized Task Scheduling
- Chrome for Developers: Use
scheduler.yield()to break up long tasks - MDN: Using Web Workers
- daily.dev: Yielding to the Main Thread in JavaScript
- Reddit r/javascript: a discussion of breaking up long tasks