How to Use scheduler.postTask() Priorities to Keep Interactions Fast
This guide extends Optimizing INP with scheduler.yield() in Core Web Vitals & Measurement from splitting work to ordering it. scheduler.yield() answers "how do I let input through in the middle of my task?". scheduler.postTask() answers the prior question: "which of my tasks should run first, and which can wait?"
Most pages queue work with setTimeout, requestAnimationFrame, promise microtasks and requestIdleCallback. None of these lets you tell the browser that rendering a filter result matters more than serialising analytics, or that pre-computing recommendations can wait until everything else is done. The Prioritized Task Scheduling API does: every task is posted with one of three priorities — user-blocking, user-visible (the default), or background — and the browser's scheduler orders them accordingly, alongside its own input handling and rendering.
Rapid Diagnosis
- Inventory deferred work. Search the codebase for
setTimeout(,requestIdleCallback(andqueueMicrotask(. Each is an implicit priority decision; most are made by accident. - Look at what runs after interactions. In a trace, after the interaction's paint, see which tasks run before the next interaction can be handled. Analytics and logging tasks queued at the same priority as UI work compete with it.
- Check for idle callbacks that never run. On busy pages
requestIdleCallbackcan be postponed for seconds; work that has a deadline but is queued as idle quietly misses it. - Check feature support.
'scheduler' in globalThis && 'postTask' in scheduler— Chromium since 94 and Firefox 142 support it; Safari does not at the time of writing.
Root Cause Analysis: Why Unprioritised Work Hurts INP
1. Everything is "normal" priority. A setTimeout for analytics and a setTimeout for rendering a result list have the same priority. Whichever was queued first runs first.
2. Microtasks cannot be interrupted. Promise continuations run to completion before the browser can handle input or render. Long chains of then callbacks behave like one long task.
3. Idle callbacks are too lazy for real work. requestIdleCallback is perfect for genuinely optional work but is commonly misused for work that must happen soon, then hacked with timeout options that turn it back into a normal task.
4. No cancellation. Work queued for a view the user has already left still runs, consuming the thread during the next view's interactions.
Step-by-Step Resolution
1. Wrap postTask with a safe fallback
// schedule.js
export function postTask(fn, { priority = 'user-visible', signal, delay = 0 } = {}) {
if (globalThis.scheduler?.postTask) return scheduler.postTask(fn, { priority, signal, delay });
return new Promise((resolve, reject) => {
const run = () => (signal?.aborted ? reject(signal.reason) : resolve(fn()));
if (priority === 'background' && 'requestIdleCallback' in globalThis) {
requestIdleCallback(run, { timeout: 2000 + delay });
} else {
setTimeout(run, delay);
}
});
}
// trade-off: the fallback approximates priorities but cannot reorder already-
// queued tasks the way the native scheduler does. On Safari, 'user-blocking'
// and 'user-visible' behave identically.
Expected outcome: one call site for all deferred work, with intent encoded in every call.
2. Classify the work around each interaction
After a click, typically three kinds of work follow: the visible response, follow-up UI (secondary panels, counters), and invisible bookkeeping. Post each at its priority.
filterButton.addEventListener('click', () => {
const query = readFilters(); // cheap, synchronous
postTask(() => renderResults(query), { priority: 'user-blocking' });
postTask(() => updateFacetCounts(query), { priority: 'user-visible' });
postTask(() => track('filter_applied', query), { priority: 'background' });
postTask(() => prefetchNextPage(query), { priority: 'background', delay: 500 });
});
// trade-off: posting the render as a separate task means the handler returns
// before results appear. The interaction's paint shows the pressed state, not
// the results — fine for INP, but show a pending indicator so it is clear
// something is happening.
Expected outcome: visible results render first; analytics and prefetching no longer compete with them or with the user's next interaction.
3. Cancel work that is no longer wanted
let controller;
function onQueryChange(q) {
controller?.abort(); // drop queued work for the old query
controller = new TaskController({ priority: 'user-visible' });
postTask(() => computeSuggestions(q), { signal: controller.signal });
}
// trade-off: TaskController is part of the same API and missing where postTask
// is missing; the wrapper above accepts a plain AbortSignal, which cancels but
// cannot re-prioritise. Feature-detect TaskController before using setPriority.
Expected outcome: stale tasks never run, freeing the thread for current interactions.
4. Re-prioritise when the user's focus changes
TaskController lets you raise or lower priority after posting — for instance, promoting a background prefetch of a panel's data to user-blocking when the user opens that panel.
const panelData = new TaskController({ priority: 'background' });
const pending = scheduler.postTask(loadPanelData, { signal: panelData.signal });
panelToggle.addEventListener('click', () => panelData.setPriority('user-blocking'));
// trade-off: re-prioritising only affects tasks that have not started yet. If
// loadPanelData is itself long, split it with scheduler.yield() so the promoted
// priority applies to its remaining chunks.
Expected outcome: speculative work becomes urgent exactly when it starts to matter.
Verification
Record the interaction and its aftermath in a throttled trace. Tasks should run in priority order after the interaction; background work should appear only after visible work completes. Measure the time from click to results painted with User Timing marks before and after: it should fall even though total work is unchanged. In the field, INP's processing duration rarely changes from prioritisation alone — the gain shows in subsequent interactions, whose input delay no longer includes background work, and in time-to-result metrics you define.
How postTask Interacts with scheduler.yield()
The two APIs compose. A task posted with postTask at a given priority can call scheduler.yield() inside; the continuation inherits the task's priority, so a background job that yields resumes as background work and a user-blocking job resumes ahead of lower-priority tasks. That inheritance is the main reason to prefer postTask + yield over setTimeout chains: the priority survives across yield points. Use postTask to decide when a job starts and at what importance, and yield inside the job to keep each chunk under 50ms. scheduler.yield() vs setTimeout(0) covers the yield side in detail.
Migrating an Existing Codebase
Adopting priorities is easiest incrementally. Start by replacing every setTimeout(fn, 0) used for deferral with postTask(fn) at the default user-visible priority — behaviour is essentially unchanged, but every deferral now goes through one function you can instrument. Next, find the analytics, logging and prefetch call sites and move them to background; this is where most of the benefit lies, because those calls are numerous and rarely urgent. Finally, look at the interactions your RUM flags as slowest and split their follow-up work into user-blocking for the visible response and lower priorities for everything else. Instrument the wrapper to count tasks by priority and duration in development, so you can see whether background work is actually staying in the background.
FAQ
Does user-blocking priority run before input handling?
No. Browser input handling and rendering remain the browser's own concerns; user-blocking tasks are prioritised among your tasks and roughly alongside rendering opportunities, but they do not preempt event dispatch. That is intentional: the API helps you order your work without letting a page starve the user's input.
Can background tasks starve?
Yes, on a page that is constantly busy at higher priorities. The browser does not guarantee background tasks run within any time. For work with a deadline — sending analytics before the page unloads, for example — use user-visible or send on visibilitychange rather than relying on a background task.
Should frameworks' own scheduling be replaced with postTask?
No. React's scheduler and Vue's async update queue manage their own ordering; wrapping their internals is fragile. Use postTask for your application-level work around the framework — data processing, analytics, prefetching — and let the framework schedule rendering.
Related
- Polyfilling scheduler.yield() across browsers — keeping the API surface uniform.
- Breaking up long tasks in Vue event handlers — applying priorities in a Vue app.
- Debouncing vs yielding for input events — reducing how often work is posted at all.