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:
postTaskis 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.yieldToMaincalls it when available, and falls back to asetTimeout-based promise otherwise — Safari has noschedulerat all, so this fallback is load-bearing, not decorative.processInChunksrunshandleItemfor 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
setTimeoutfallback 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.postTaskisn't recognized in your project, that's a lib/version gap, not a bug in your code — theglobalThiscast in this snippet works regardless of whether those ambient types are present. - A task's
priorityis fixed for its lifetime unless you pass aTaskController'ssignalinstead of a plainAbortSignal— pass anAbortSignaland you get cancellation only; pass aTaskSignal(fromnew TaskController({ priority })) and you get both cancellation andsetPriority(). - 50 items per yield is a starting point, not a rule — the right chunk size depends on how expensive
handleItemis 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;postTasksupersedes 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.