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) and background are 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 await and with Promise.all like 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.

Where each priority lane sits relative to rendering and input Four lanes ordered by how the browser interleaves them. Input handling and user-blocking tasks run first and are expected to complete within a frame. Rendering follows. User-visible tasks, the default priority, run after rendering but ahead of background work. Background tasks run only when nothing above them is pending, and may be postponed indefinitely on a busy page. A note records that all four lanes belong to the calling agent's event loop, so scheduling a dispatch at background priority delays the postMessage, not the worker's execution. Priority lanes on one event loop input handlers · user-blocking expected to finish inside the current frame — dispatch here only if a user is waiting style, layout, paint the frame the user actually sees user-visible — the default after rendering, before background: the right lane for most worker dispatches background runs only when nothing above is pending — may be postponed for a long time All four lanes belong to the calling agent. Scheduling a dispatch at background priority delays the postMessage — it does not slow the worker down once the message arrives.
The distinction in the footnote is the one that trips people up: 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');
  }
});
Lifecycle of a scheduled worker job A job moves through four states. Queued: the task is waiting in its priority lane and can be aborted for free or promoted to a higher lane in place. Dispatched: the callback ran and the message was posted, so abort now requires a cooperative cancel message that the worker checks between slices. Running: the worker is executing and nothing preempts it. Settled: the reply arrived and the promise resolved, or the worker threw and it rejected. Arrows mark the two abort paths, one free and one cooperative. Where a job can still be changed, and where it cannot queued abort is free setPriority reorders dispatched message posted — abort needs cooperation running nothing preempts it only its own yields help settled reply resolves, or a throw rejects controller.abort() before dispatch — rejects immediately, no CPU spent Design consequence Keep the postTask callback tiny — post the message and nothing else. Work done inside the callback runs on the main thread, in a lane, and is exactly what you were trying to avoid.
The design consequence is the practical takeaway: a fat 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.


Which lane a dispatch belongs in Four dispatch situations mapped to a priority. Work behind a spinner the user is watching belongs in user-blocking. Work that affects what is on screen but is not immediately awaited belongs in user-visible, the default. Prefetching and cache warming belong in background. And work that may become urgent should start in user-visible and be promoted, never start in background and be promoted late. Choosing the lane at the call site the user is watching a spinner for it user-blocking — dispatch immediately, keep the callback tiny it changes the screen but nobody is waiting on it user-visible — the default, and correct for most dispatches prefetching, cache warming, index building background — may be postponed for seconds on a busy page it might become urgent while queued start at user-visible and promote; background may never run A TaskController makes the last row cheap: setPriority reorders the task in place.
The bottom row is the one that decides between a responsive feature and one that appears only after the user has given up.

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.

Frequently Asked Questions

Does scheduler.postTask make the worker itself run at a lower priority?
No. postTask schedules a callback on the event loop of the agent that called it. Scheduling the dispatch at background priority delays when the message is posted; once the worker receives it, the work runs at whatever pace that thread runs — the platform exposes no OS-level thread priority to JavaScript. Priority between worker jobs is therefore always a property of your dispatch order.
Is scheduler available inside a worker?
In Chromium-based engines, yes — WorkerGlobalScope exposes scheduler, so a worker can order its own internal tasks the same way a page can. It is not universal, so feature-detect and fall back to a message-channel macrotask. The fallback preserves the yielding behaviour; only the priority ordering is lost.

See also