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.
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.yieldandisInputPendinghave 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.
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.
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.
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.