10LOC
Chrome APIsadvanced

scheduler.postTask to yield to the main thread under load

Published October 30, 2026

type TaskPriority = "user-blocking" | "user-visible" | "background";
type PostTask = (cb: () => void, opts?: { priority?: TaskPriority }) => Promise<void>;

const postTask = (globalThis as { scheduler?: { postTask: PostTask } }).scheduler?.postTask;

const yieldToMain = (priority: TaskPriority = "user-visible") =>
  postTask ? postTask(() => {}, { priority }) : new Promise<void>((resolve) => setTimeout(resolve, 0));

export const processInChunks = async <T>(items: T[], handleItem: (item: T) => void) => {
  for (const [index, item] of items.entries()) {
    handleItem(item);
    if (index % 50 === 49) await yieldToMain();
  }
};

What

scheduler.postTask(callback, options) queues a chunk of work with an explicit priority (user-blocking, user-visible, or background) and hands control back to the browser between chunks, so input and rendering aren't starved by a long-running loop.

Why it matters

The classic way to break up a long task is setTimeout(fn, 0): it yields to the event loop, but it has no concept of priority (every deferred callback is equal), it's clamped to a minimum ~4ms delay after a handful of nested calls, and cancelling a batch of scheduled work means manually tracking timeout IDs. requestIdleCallback has priority in the loose sense of "only when idle," but no way to say "this chunk matters more than that one" or to promote a background chunk to urgent once the user starts interacting.

postTask was built specifically for this: chunks carry a priority the browser's scheduler actually uses to order work against rendering and input, and a task's priority can change mid-flight via TaskController.setPriority() — useful when, say, a background prefetch should jump the queue because the user just navigated toward it.

How it works

The snippet reads scheduler.postTask off globalThis through a cast rather than referencing a bare scheduler global directly:

  • postTask is looked up once as (globalThis as { scheduler?: ... }).scheduler?.postTask — this sidesteps depending on whichever ambient DOM types your TypeScript setup happens to ship (see Gotchas) and doubles as the feature-detection check.
  • yieldToMain calls it when available, and falls back to a setTimeout-based promise otherwise — Safari has no scheduler at all, so this fallback is load-bearing, not decorative.
  • processInChunks runs handleItem for every item, yielding every 50 items so a 10,000-item loop doesn't block the main thread for one uninterrupted stretch.

Gotchas

  • Safari does not implement the Scheduler API at all as of this writing — the setTimeout fallback isn't optional polish, it's required for correctness on that engine.
  • TypeScript's bundled DOM types don't consistently include the Scheduler API across versions. If scheduler.postTask isn't recognized in your project, that's a lib/version gap, not a bug in your code — the globalThis cast in this snippet works regardless of whether those ambient types are present.
  • A task's priority is fixed for its lifetime unless you pass a TaskController's signal instead of a plain AbortSignal — pass an AbortSignal and you get cancellation only; pass a TaskSignal (from new TaskController({ priority })) and you get both cancellation and setPriority().
  • 50 items per yield is a starting point, not a rule — the right chunk size depends on how expensive handleItem is per call; profile before picking a number.

Browser support: Chrome/Edge since 2021, Firefox since version 142 (mid-2025). Safari has no support and no public plan to add it — always ship the setTimeout fallback.

Related

  • requestIdleCallback — runs work only when the browser is otherwise idle, with no priority levels; postTask supersedes it for anything that needs to compete against rendering deliberately.
  • Web Workers — for CPU-bound work that doesn't need the main thread at all, moving it off-thread beats yielding on it.