Task Scheduling & Prioritization

Offloading answers where work runs. Scheduling answers when, and in what order β€” and no amount of offloading fixes a 300 ms DOM update, a background job that starves an urgent one, or a worker pool that dispatches in the order requests happened to arrive.

This topic belongs to High-Performance Computation Patterns and covers both halves: the main-thread primitives that keep unavoidable work interruptible, and the dispatch machinery that gives a worker pool a meaningful notion of priority β€” since the platform gives it none.


The Symptom: A Responsive Thread That Still Feels Slow

A table view offloads its filtering to a worker. The profile is exemplary: the worker computes for 180 ms, the main thread sits idle. Interaction to Next Paint is still 420 ms, and typing in the filter box feels sticky.

The time is in three places the offloading never touched:

  • A 210 ms DOM update when the result arrives β€” 8,000 rows built and inserted in one task. It cannot move to a worker, because it is the DOM.
  • A queue with no order. Every keystroke enqueued a filter job; the worker is still working through keystroke three when keystroke seven arrives, because the pool dispatches first-in-first-out.
  • Background work competing. A prefetch and an analytics flush were scheduled at default priority, so the browser interleaves them with the input handler.

Scheduling addresses all three, and none of them is a computation problem.

Where the latency of one keystroke actually accumulates A breakdown of a 420 millisecond interaction. Only 180 milliseconds is worker computation, and it runs off the main thread. The remaining time is main-thread: 60 milliseconds waiting behind a background prefetch task that was scheduled at default priority, 130 milliseconds of stale queued work for earlier keystrokes that the pool dispatched in arrival order, and 210 milliseconds of DOM construction when the result finally lands. A second bar shows the same interaction after scheduling changes: the prefetch runs at background priority and is preempted, superseded keystrokes are dropped from the queue before dispatch, and the DOM update is sliced into yielding chunks, leaving a 90 millisecond interaction. One keystroke, before and after scheduling before prefetch 60 stale queued jobs 130 DOM build 210 β€” one task 420 ms worker computation 180 ms β€” off the main thread already fine; not the problem after 90 ms β€” prefetch deferred, stale jobs dropped, DOM sliced 0 140 280 420 ms DOM slices, yielding between each the one job that still matters Not one millisecond of computation was optimised. Only order and granularity changed.
The olive bar is unchanged between the two runs β€” evidence that a scheduling problem cannot be solved by making the computation faster.

Prerequisites

  • Field or lab measurements of Interaction to Next Paint, since that is what scheduling changes. Without it, improvements are indistinguishable from placebo.
  • A list of the work the page performs that is not user-visible β€” prefetching, analytics, cache warming, index building. That list becomes your background priority class.
  • A worker pool with a dispatch point you control, per Worker Pool Management. Priority is implemented at dispatch; a pool that posts directly to a worker has nowhere to put it.
  • Feature detection for the scheduling APIs, because scheduler.postTask, scheduler.yield and isInputPending have different availability across engines.

Step 1 β€” Classify Before Scheduling

Three classes cover almost everything, and the boundaries are behavioural rather than technical.

Class Definition Where it runs Priority
Blocking A user is watching a spinner for it main thread if it touches the DOM, else the pool at the front of the queue user-blocking
Visible Affects what is on screen but not immediately awaited pool, normal queue user-visible
Background Nobody is waiting; only completion matters pool tail, or idle time background

The classification is per invocation, not per function. The same aggregation is blocking when a user clicks β€œrecalculate” and background when it runs on a timer. Passing the class in with the request β€” rather than inferring it inside the worker β€” keeps that distinction where the knowledge is.


Step 2 β€” Yield on the Main Thread

Work that touches the DOM stays on the main thread; the only lever is granularity. A 210 ms update becomes fourteen 15 ms slices with a yield between them, and input handled between slices is what the user perceives as responsiveness.

// yield-safe chunking with progressive fallbacks
const yieldToMain = (): Promise<void> => {
  if ('scheduler' in globalThis && 'yield' in (globalThis as never)['scheduler']) {
    return (globalThis as never)['scheduler'].yield();      // continuation keeps priority
  }
  return new Promise((resolve) => setTimeout(resolve, 0));  // works everywhere
};

export async function renderRows(rows: Row[], into: HTMLElement): Promise<void> {
  const SLICE_MS = 12;                       // stay under one frame
  let start = performance.now();
  const frag = document.createDocumentFragment();

  for (let i = 0; i < rows.length; i++) {
    frag.append(buildRow(rows[i]));
    if (performance.now() - start > SLICE_MS) {
      into.append(frag);                     // flush what we have
      await yieldToMain();                   // input and paint may run here
      start = performance.now();
    }
  }
  into.append(frag);
}

scheduler.yield() is materially better than setTimeout(0) where available: the continuation is scheduled ahead of newly arriving tasks of the same priority, so yielding does not send your work to the back of the queue behind unrelated work. With setTimeout, a page with chatty third-party scripts can make a sliced loop take several times longer in wall clock.

Yielding costs total time and buys latency

Fourteen slices with a yield between each adds roughly 3–8 ms of overhead to a 210 ms job β€” under 4%. In exchange the longest task drops from 210 ms to about 15 ms, which is the number Interaction to Next Paint actually measures. Trade total throughput for tail latency wherever a human is waiting; do the opposite for batch work.


Step 3 β€” Give the Pool a Priority Queue

A worker’s event loop is strictly first-in-first-out and nothing preempts a running task. Priority therefore has to exist before dispatch, in a queue the page owns.

// priority-pool.ts β€” dispatch order is the only priority a worker has
type Priority = 'blocking' | 'visible' | 'background';
const RANK: Record<Priority, number> = { blocking: 0, visible: 1, background: 2 };

interface Job<T> { priority: Priority; enqueued: number; run: (w: Worker) => Promise<T>; }

export class PriorityPool {
  #queue: Job<unknown>[] = [];
  #idle: Worker[] = [];

  submit<T>(priority: Priority, run: (w: Worker) => Promise<T>): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      this.#queue.push({
        priority, enqueued: performance.now(),
        run: (w) => run(w).then(resolve as never, reject) as Promise<unknown>,
      });
      this.#pump();
    });
  }

  #next(): Job<unknown> | undefined {
    // Ageing: after 2 s of waiting a job is treated as one class more urgent,
    // which is what stops a stream of blocking work starving the background class.
    const now = performance.now();
    const score = (j: Job<unknown>) => RANK[j.priority] - Math.min(2, (now - j.enqueued) / 2_000);
    this.#queue.sort((a, b) => score(a) - score(b));
    return this.#queue.shift();
  }

  #pump(): void {
    while (this.#idle.length && this.#queue.length) {
      const worker = this.#idle.pop()!;
      const job = this.#next()!;
      void job.run(worker).finally(() => { this.#idle.push(worker); this.#pump(); });
    }
  }
}

Two design choices deserve attention. Ageing is not optional in any system where the blocking class can be produced continuously β€” without it, a user typing steadily prevents the index rebuild from ever starting. And superseded jobs should be dropped, not run: a filter for keystroke three has no value once keystroke seven exists, so the submitter should hold a cancellation handle and drop stale entries before dispatch rather than letting the pool work through history.

Dispatch order under a priority queue with ageing A queue of six jobs waiting for two idle workers. Two blocking jobs, submitted most recently, sort to the front. A visible job submitted earlier follows. A background job that has waited four seconds has aged by two classes and now sorts ahead of a newer visible job, which is what prevents starvation. A superseded blocking job from an earlier keystroke is marked dropped before dispatch rather than executed. Below, a note records that nothing preempts a task once a worker has started it, so task granularity decides how quickly an urgent job can begin. What the dispatcher picks next blocking newest keystroke blocking click handler background, aged 4 s promoted past newer work visible chart refresh superseded dropped before dispatch two idle workers take the two leftmost jobs Nothing preempts a running task β€” granularity decides how soon urgent work can start.
Ageing turns the queue from a strict hierarchy into a fairness policy. Without it the mustard job never runs on a busy page.

Step 4 β€” Make Worker Tasks Interruptible

Because nothing preempts a running worker task, a 900 ms job means an urgent request waits up to 900 ms even in an otherwise idle pool. The fix is inside the task: process in slices, and let the event loop run between them so an incoming cancel or a higher-priority handoff can be seen.

// worker.ts β€” a long job that stays responsive to its own inbox
let cancelled = false;
self.addEventListener('message', (e) => { if (e.data?.kind === 'CANCEL') cancelled = true; });

const drainInbox = () => new Promise<void>((r) => setTimeout(r, 0));  // one macrotask turn

export async function aggregate(rows: Float64Array): Promise<number> {
  let sum = 0;
  for (let i = 0; i < rows.length; i++) {
    sum += rows[i];
    if ((i & 0xffff) === 0) {                 // every 65,536 iterations, ~2–5 ms
      await drainInbox();                     // messages arrive here, and only here
      if (cancelled) throw new DOMException('aborted', 'AbortError');
    }
  }
  return sum;
}

A Promise.resolve() microtask is not sufficient: microtasks drain without returning to the event loop, so a pending message event is not delivered. It has to be a macrotask β€” setTimeout(0), a MessageChannel ping, or scheduler.yield() where the worker scope has it.


A Worked Example: The Filter Box

Putting the four steps together on the interaction from the opening section makes the division of labour concrete.

// filter-controller.ts β€” one keystroke, end to end
let inFlight: { ctl: AbortController; seq: number } | null = null;
let seq = 0;

input.addEventListener('input', () => {
  const mine = ++seq;

  // 1. Drop the previous job before it is dispatched, and cancel it if it started.
  inFlight?.ctl.abort();
  const ctl = new AbortController();
  inFlight = { ctl, seq: mine };

  // 2. Blocking class: the user is watching. Dispatch immediately.
  void pool.submit('blocking', (worker) => worker.request(
    { kind: 'FILTER', query: input.value }, { signal: ctl.signal }))
    .then(async (rows: Row[]) => {
      if (mine !== seq) return;                 // superseded while computing
      // 3. The DOM update is main-thread work β€” slice it.
      await renderRows(rows, tableBody);
    })
    .catch((error) => { if (error.name !== 'AbortError') report(error); });
});

// 4. Background work is submitted once and left to age into service.
void pool.submit('background', (worker) => worker.request({ kind: 'REBUILD_INDEX' }));

Four mechanisms appear, one per problem identified earlier. The abort controller prevents the pool from working through obsolete keystrokes. The priority class puts the newest filter ahead of the index rebuild. The sequence check discards a result that lost the race despite everything. And renderRows β€” the yielding loop from Step 2 β€” keeps the DOM update from becoming the new bottleneck once the others are fixed.

What is not here is equally deliberate: no debounce. Debouncing hides a scheduling problem by delaying every interaction, including the ones the system could have served immediately. With cancellation and priority in place, a fast machine answers each keystroke and a slow one naturally coalesces, because superseded jobs are dropped before they run. Add a debounce afterwards only if measurement shows the dispatch itself is the cost.


Anti-Patterns Worth Naming

Debounce as a substitute for cancellation. A 300 ms debounce adds 300 ms to every interaction to save work on some of them. Cancellation costs nothing and only affects the interactions that were superseded.

Priority as a permanent attribute of a function. The same aggregation is urgent on click and background on a timer. Priority belongs to the invocation; baking it into the worker method means the worker cannot serve both callers.

One pool for every class of work. A pool sized to the core count, shared between a latency-critical filter and a long import, means the import occupies every worker for a minute. Either give the background class a smaller dedicated pool, or reserve at least one slot for the blocking class so it can always start immediately.

Yielding by awaiting a resolved promise. await Promise.resolve() is a microtask; the event loop is not reached, no input is handled, no message is delivered. Only a macrotask yields in a way that matters.

Measuring on a fast machine. Scheduling problems are invisible when everything finishes in 8 ms. Throttle the CPU to 4Γ— or 6Γ— in DevTools before deciding whether an ordering change helped.


Data-Transfer Strategy Under Scheduling

Situation Choice Reason
Urgent, small input clone, dispatch immediately 0.05 ms of clone is cheaper than any cleverness
Urgent, large input transfer avoids a copy on the critical path
Background bulk transfer, batched larger throughput matters, latency does not
Repeatedly queried dataset load once into the worker, send queries avoids re-sending per request
Cancellable long job keep data in the worker, send control messages cancellation must not queue behind data

The last row is the one scheduling adds to the usual advice: control messages and data compete for the same message queue, so a cancel posted behind a 40 MB payload waits for the payload to be delivered and cloned. Keep control traffic on a separate MessagePort when jobs are large enough for that to matter.


Verification & Measurement

Long Animation Frames. The long-animation-frame entry type reports frames over 50 ms with attribution to the script that caused them β€” more useful than the older long-task entries, which report duration with no attribution:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.warn('LoAF', entry.duration, (entry as never as { scripts: unknown[] }).scripts);
  }
}).observe({ type: 'long-animation-frame', buffered: true });

Queue wait time. Record dispatchTime βˆ’ enqueueTime per priority class. Blocking work should wait single-digit milliseconds; if it does not, the pool is undersized or tasks are too coarse. Background wait time growing without bound means ageing is missing or misconfigured.

Interaction to Next Paint in the field. Lab profiles show what a fast machine does. INP from real users shows whether the ordering is right on the devices that matter, and it is the metric that moves when scheduling improves.


Which signal points at which scheduling problem Four measurable signals and the problem each one identifies. A long task on the main thread with no worker activity means work that should have been offloaded. A long task made of DOM work means a slice budget is missing. A high queue wait time for blocking jobs means the pool has no free worker. And background jobs that never complete mean ageing is missing from the queue. Reading the symptom back to the cause a long task on the main thread, worker tracks idle pure computation that was never offloaded a long task made of DOM work no slice budget β€” yield between units of work blocking jobs waiting in the queue for tens of milliseconds the pool has no free worker; reserve a slot background jobs that never finish ageing is missing, so urgent work starves them forever Each row is a different fix. Only the first is solved by adding a worker.
Scheduling problems are diagnosable from two numbers β€” where the long tasks are, and how long jobs wait before dispatch.

Failure Modes & Error Handling

Starvation. A continuously produced high-priority class postpones everything below it forever. Ageing fixes it; a maximum wait time that force-promotes is the belt-and-braces version.

Priority inversion. An urgent job waits on a resource β€” a lock, a warm cache entry, a shared parse β€” held by a background job that is not being scheduled. The pragmatic remedy is to avoid shared mutable resources between priority classes; where that is impossible, promote the holder when a higher-priority waiter appears.

Yielding in the wrong place. A yield in the middle of a DOM mutation sequence can expose a half-built view for a frame. Slice at boundaries that leave the DOM consistent, and build into a fragment when the intermediate state would be visible.

Promotion without re-dispatch. Raising a job’s priority while it sits in a queue only helps if the dispatcher re-evaluates the queue afterwards. A pool that sorts on insertion and never again will honour the old order regardless of what the priority field now says.

Hidden-tab throttling. Timers clamp to once per minute, requestAnimationFrame stops, and some engines freeze the tab. A long background job should checkpoint its progress so it can resume rather than restart, and should treat visibilitychange as a scheduling event.

Ordering assumptions after prioritisation. Reordering the queue means results no longer arrive in submission order. Any consumer that appended results blindly now interleaves them β€” attach a sequence number to every job and discard or reassemble accordingly.

Cancellation that never lands. A cancel message is useless if the target task never yields β€” see Step 4. Test cancellation against your longest task, not a synthetic short one.


Advanced: Reserving Capacity for Urgent Work

A pool that is fully occupied by background jobs cannot start an urgent one until something finishes, and β€œsomething finishes” can be a minute away for a long import. Priority ordering does not help β€” the queue is not the problem, the workers are.

Two reservation strategies solve it, and they cost very little.

Reserve a slot. Keep one worker that only ever accepts blocking work. On a four-worker pool that gives up 25% of background throughput and guarantees an urgent job starts within one task’s granularity. The arithmetic favours reservation whenever background jobs are long and urgent jobs are rare, which describes most applications.

#pick(job: Job): Worker | undefined {
  // The last worker is reserved: only blocking work may use it.
  const usable = job.priority === 'blocking' ? this.#idle : this.#idle.slice(0, -1);
  return usable.pop();
}

Split the pools. A separate single-worker pool for background work, sized independently of the interactive one. Slightly more memory β€” one extra thread at 2–5 MB β€” and complete isolation: an import cannot occupy an interactive slot even briefly, and the two pools can have different worker scripts, so the background worker can load heavy dependencies the interactive one does not need.

Either way, the guarantee to aim for is a bound on time-to-start for the blocking class, and it should be measured rather than assumed. Log dispatchTime βˆ’ enqueueTime for blocking jobs and treat a p95 above one task’s worth of latency as a capacity bug rather than a tuning preference β€” no amount of queue reordering fixes a pool with no free worker.


Browser Compatibility

Capability Chrome Firefox Safari Edge
requestIdleCallback 47+ 55+ 17.4+ 79+
scheduler.postTask 94+ recent versions not yet β€” polyfill 94+
scheduler.yield 129+ not yet not yet 129+
navigator.scheduling.isInputPending 87+ no no 87+
long-animation-frame entries 123+ no no 123+
MessageChannel yield fallback βœ… βœ… βœ… βœ…

Only the last row is universal, which is why the yielding helper above falls back rather than feature-gating the behaviour. Write the scheduling layer so that every API in the table is an optimisation over a portable baseline, and the code keeps working as support improves.

Frequently Asked Questions

If I have workers, do I still need main-thread scheduling?
Yes. Anything that touches the DOM β€” building rows, measuring layout, applying a diff β€” cannot move to a worker, and that work still produces long tasks. Workers remove computation from the main thread; scheduling decides how the remaining, unavoidable main-thread work is broken up so input can be handled between the pieces.
What is the difference between yielding and offloading?
Yielding keeps the work on the main thread but lets the browser interleave input handling and rendering between slices, so the page stays responsive while total time gets slightly worse. Offloading moves the work to another thread entirely, so the main thread is free but every input and output must cross a boundary. DOM work must yield; pure computation should offload; work that is mostly DOM with a computational core should split.
Does a worker task have a priority?
Not from the platform β€” a worker runs its own event loop first-in, first-out and nothing preempts a running task. Priority in a worker context is something the dispatcher implements: a priority queue on the sending side that decides dispatch order, plus tasks written to yield often enough that a newly arrived urgent job does not wait behind 400 ms of arithmetic.
Why does my background job stop when the tab is hidden?
Browsers throttle hidden tabs aggressively: timers are clamped to once per minute after a few minutes, requestAnimationFrame stops entirely, and some engines freeze background tabs outright to save power. Workers are throttled less than timers but are not exempt. Treat progress in a hidden tab as best-effort, checkpoint long jobs, and resume on visibilitychange.

See also