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.

The three postTask priorities The three priority levels of scheduler.postTask with what kind of work belongs in each and how the browser treats them. The three postTask priorities user-blocking work the user is waiting on right now — the visible response to an interaction user-visible default: work with visible results that is not blocking an interaction background analytics, prefetching, cache warming, logging — runs when nothing else is waiting

Rapid Diagnosis

  • Inventory deferred work. Search the codebase for setTimeout(, requestIdleCallback( and queueMicrotask(. 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 requestIdleCallback can 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.

Post-click work with and without priorities Two timelines of work after a click; without priorities analytics runs before the result render, with priorities the render runs first and analytics last. Post-click work with and without priorities Unordered handler analytics render results prefetch Prioritised handler render results prefetch analytics 0ms 50ms 100ms 150ms 200ms 250ms 300ms Same total work. With priorities, the visible result is ready 80ms sooner and background work no longer delays it.

Step-by-Step Resolution

1. Wrap postTask with a safe fallback

javascript
// 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.

javascript
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

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

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

Where common work belongs Recommended postTask priority for common categories of frontend work, with the fallback used when the API is missing. Where common work belongs Work Priority Fallback Rendering the response to a click user-blocking setTimeout 0 Secondary UI updates user-visible setTimeout 0 Analytics and logging background requestIdleCallback Prefetching and cache warming background + delay requestIdleCallback Cleanup of a previous view background requestIdleCallback

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.

/html>