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)
Seeing Scheduler does not mean every function needs a queue. First decide whether this work must finish now or can let the browser handle it later. · Source: GIPHY

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 current async function 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)
Earlier is not always better. Ask whether the work actually blocks a person before choosing synchronous code, Scheduler, or a Worker. · Source: GIPHY

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