Using scheduler.postTask with Workers
scheduler.postTask gives the browser’s own task queue three priority lanes and a cancellation handle. Combined with a worker, it decides when a job is handed over — which, since nothing preempts a running worker task, is the only priority a worker job will ever have.
This guide implements one piece of Task Scheduling & Prioritization, under High-Performance Computation Patterns.
The API in One Example
// dispatch.ts — schedule the hand-off, then await the worker's reply
const controller = new TaskController({ priority: 'user-visible' });
const resultPromise = scheduler.postTask(
async () => {
// This callback runs on the main thread at the chosen priority.
// Keep it tiny: its whole job is to post the message.
return api.recalculate(params); // the worker does the actual work
},
{ signal: controller.signal, priority: 'user-visible' },
);
// The user typed again — this job no longer matters.
supersededByNewInput.then(() => controller.abort());
// Or: the user is now watching it, so promote it.
onPanelOpened(() => controller.setPriority('user-blocking'));
Three properties of postTask do work that setTimeout cannot:
- Priority lanes.
user-blocking,user-visible(the default) andbackgroundare honoured by the browser’s scheduler relative to rendering, input and other tasks — not merely relative to each other. - A returned promise. The callback’s return value settles it, so a scheduled task composes with
awaitand withPromise.alllike any other async work. - A
TaskController. Abort a task that has not started, and change the priority of one that is still queued. Neither is possible with a timer.
The delay option adds a minimum wait before the task becomes eligible, which makes postTask a strict superset of setTimeout for this purpose.
postTask orders your code, not the other thread's.Wiring It to a Pool
The natural integration point is the pool’s submit. Each job carries a priority and a signal; the pool schedules its dispatch through postTask and lets the controller handle both cancellation and promotion.
// scheduled-pool.ts
type Prio = 'user-blocking' | 'user-visible' | 'background';
export class ScheduledPool {
constructor(private pool: { run<T>(job: () => Promise<T>): Promise<T> }) {}
submit<T>(priority: Prio, job: () => Promise<T>): { result: Promise<T>; ctl: TaskController } {
const ctl = new TaskController({ priority });
const result = postTaskOrFallback(() => this.pool.run(job), ctl.signal, priority);
return { result, ctl };
}
}
function postTaskOrFallback<T>(fn: () => Promise<T>, signal: AbortSignal, priority: Prio): Promise<T> {
if ('scheduler' in globalThis && 'postTask' in (globalThis as never)['scheduler']) {
return (globalThis as never)['scheduler'].postTask(fn, { signal, priority });
}
// Fallback: background work waits for idle; everything else goes next turn.
return new Promise<T>((resolve, reject) => {
const start = () => (signal.aborted ? reject(signal.reason) : fn().then(resolve, reject));
if (priority === 'background' && 'requestIdleCallback' in globalThis) {
requestIdleCallback(start, { timeout: 5_000 });
} else {
const ch = new MessageChannel(); // a macrotask, without setTimeout clamping
ch.port1.onmessage = start;
ch.port2.postMessage(null);
}
});
}
The fallback matters more than it looks. A MessageChannel ping is a real macrotask with no 4 ms clamping, so it behaves like postTask for the two foreground lanes; requestIdleCallback with a timeout is a reasonable stand-in for background. Feature detection then buys ordering quality rather than functionality, which is the right way round.
Cancellation and Promotion in Practice
The lifecycle these two operations create is what makes postTask worth adopting over a hand-rolled queue.
Abort before dispatch is free. A superseded search, a chart for a panel that closed, a prefetch for a route the user left — aborting removes the task from the queue and the promise rejects with the signal’s reason. Nothing is sent to the worker, so no CPU is spent at all.
Abort after dispatch needs cooperation. Once the message is posted the worker owns the job, and the abort has to travel as a message the worker’s loop checks — see the interruptible-task pattern in the parent topic. TaskController does not reach across the boundary.
Promotion is the underrated one. Work often changes urgency after being queued: a background thumbnail becomes user-blocking when the user scrolls to it. ctl.setPriority('user-blocking') reorders a queued task in place, which is considerably simpler than cancelling and resubmitting — and it preserves the promise, so awaiting code is unaffected.
// A thumbnail queue that promotes what scrolls into view
const queued = new Map<string, TaskController>();
function enqueueThumbnail(id: string) {
const { result, ctl } = pool.submit('background', () => renderThumb(id));
queued.set(id, ctl);
void result.finally(() => queued.delete(id));
}
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
queued.get((entry.target as HTMLElement).dataset.id!)?.setPriority('user-blocking');
}
});
postTask callback is a long task with a priority label on it.Scheduling Inside the Worker
Chromium exposes scheduler on WorkerGlobalScope too, which lets a worker order its own internal tasks — useful when one worker serves several kinds of request and some are more urgent than others.
// worker.ts — order internal work, with a portable fallback
const yieldTurn = (): Promise<void> =>
'scheduler' in self && 'yield' in (self as never)['scheduler']
? (self as never)['scheduler'].yield()
: new Promise((r) => { const c = new MessageChannel(); c.port1.onmessage = () => r(); c.port2.postMessage(0); });
async function handleBulk(rows: Float64Array) {
for (let i = 0; i < rows.length; i += 50_000) {
process(rows.subarray(i, i + 50_000));
await yieldTurn(); // an urgent message can be delivered here
}
}
Even without the priority lanes, the yield is what makes a worker capable of noticing anything while it works. A worker that never yields has a message queue that only drains between whole jobs, which is why an “urgent” request to a busy worker can appear to be ignored for hundreds of milliseconds.
Composing Signals: Timeouts, Teardown and User Cancellation
Real jobs are cancelled for several unrelated reasons at once — the component unmounted, the user pressed escape, a deadline expired. AbortSignal.any combines them without turning the call site into bookkeeping, and a TaskSignal from a TaskController composes with ordinary abort signals:
const deadline = AbortSignal.timeout(5_000); // give up after five seconds
const teardown = componentAbortController.signal; // unmount
const task = new TaskController({ priority: 'user-visible' });
const { result } = pool.submitWith({
priority: 'user-visible',
signal: AbortSignal.any([task.signal, deadline, teardown]),
job: (worker) => worker.request({ kind: 'RENDER_TILE', id }),
});
result.catch((error) => {
if (error.name === 'TimeoutError') markSlow(id); // distinguishable causes
else if (error.name !== 'AbortError') report(error);
});
Two details make this pleasant in practice. The rejection carries the reason from whichever signal fired, so a timeout is distinguishable from an unmount without extra flags — worth using, because the two deserve different telemetry. And combining signals does not lose the priority: task.setPriority() still reorders the queued task, because the TaskSignal remains the one the scheduler consulted when the task was posted.
The one thing composition cannot give you is cancellation of work already running in the worker. Whatever the reason, the abort still has to reach the worker as a message and be observed between slices. A useful convention is to make the pool post a CANCEL on the job’s port whenever its signal fires, so every call site gets cooperative cancellation without writing it — and every worker method is expected to check for it.
Gotchas
Doing the work in the callback. scheduler.postTask(() => parse(bigString)) runs the parse on the main thread — in a lane, still blocking. The callback should post a message and return the promise for its reply.
Assuming background will eventually run. It runs when nothing above it is pending, which on a busy page can be a long time. If completion is required, use user-visible with a delay, or add your own deadline that promotes the task.
Using background for work a user will wait for. The lane is genuinely deprioritised: on a page with continuous animation it may not run for seconds. If there is any chance the user opens the panel that needs the result, submit at user-visible and promote rather than the reverse.
Aborting after dispatch and assuming it stopped. The promise rejects, the worker keeps computing. Without a cooperative cancel the CPU is spent regardless, and on a pool it blocks a slot.
Assuming a polyfill reproduces the ordering. Most postTask shims map priorities onto timers and idle callbacks, which approximates the behaviour but does not give the browser’s scheduler any real information. Treat a polyfill as a compatibility shim, not as a performance feature.
setPriority on a TaskSignal you did not create. Only the controller can change priority; a signal derived from an AbortController has no priority to change and throws.
Treating the returned promise as a completion signal for the worker. It settles when the callback settles. If the callback returns before the worker replies — a missing return on the request promise — the task looks finished while the work has barely started, and cancellation silently stops applying.
Priority applied to the wrong agent. A worker’s own postTask orders tasks inside that worker; it has no effect on the page’s ordering, and vice versa. Each agent schedules only itself.
Performance Note
On a page with a busy third-party analytics script, dispatching sixteen thumbnail jobs through setTimeout(0) took 340 ms before the last message was posted, because each timer landed behind unrelated tasks. The same sixteen dispatched with scheduler.postTask at background, with visible ones promoted to user-blocking on intersection, posted the visible four within 12 ms and finished the rest during idle time — same total work, and the four the user was actually looking at arrived roughly 28× sooner.