Use scheduler.postTask(fn, { priority, signal }) to give observer-triggered work an explicit priority — user-visible for things the user will see soon, background for bookkeeping — and a TaskController per element so work is cancelled or re-prioritised when the element leaves the viewport.
Problem / Scenario Context
A dashboard renders a dozen chart widgets in a scrolling column. Each widget is registered with an IntersectionObserver using a generous rootMargin, so charts render just before they scroll in. Rendering a chart takes 15–40 ms. When the page loads, eight widgets are inside the margin at once, and all eight render in the first callback — 200 ms of work that makes the filter controls at the top unresponsive for the first seconds of every visit. Worse, when a user scrolls quickly past a chart, its render still runs even though it is already off-screen.
The work needs ordering (visible charts first), cancellation (charts that left) and interleaving with input. The Scheduling Observer Work Off the Critical Path topic introduces the priorities; this page applies them per element.
Mechanics Explanation
The Prioritized Task Scheduling API adds scheduler.postTask(), which queues a task at one of three priorities:
user-blocking— work that blocks the user from interacting, such as responding to input. Runs before everything else.user-visible(default) — work the user will notice soon, like rendering content that is about to be seen.background— work the user does not observe: logging, cache warming.
Each task can be tied to an AbortSignal (cancel) or a TaskSignal from a TaskController (cancel and change priority later with controller.setPriority()). Tasks at the same priority run in FIFO order; higher priorities always drain first. The returned promise resolves with the callback's return value or rejects with an AbortError if cancelled.
For observer work this maps neatly: an element inside the viewport gets user-visible, an element only inside the margin gets background, and an element that leaves entirely gets its task aborted.
Comparison Table: Scheduling Options for Per-Element Work
| Approach | Ordered by visibility? | Cancellable? | Re-prioritisable? | Support |
|---|---|---|---|---|
| Render in the callback | no | no | no | everywhere |
setTimeout per element |
FIFO only | clearTimeout |
no | everywhere |
requestIdleCallback per element |
no | yes | no | most |
postTask + AbortController |
yes, via priority | yes | no | Chromium, Firefox |
postTask + TaskController |
yes | yes | yes | Chromium, Firefox |
Own priority queue + scheduler.yield |
yes | yes | yes | depends on fallback |
Minimal Reproducible Example
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) renderChart(e.target as HTMLElement); // 15–40 ms each, all now
}
}, { rootMargin: '800px 0px' });
document.querySelectorAll('.widget').forEach((w) => io.observe(w));
declare function renderChart(el: HTMLElement): void;
Load the dashboard and immediately click a filter: the click waits behind every chart inside the 800 px margin.
Production-Safe Solution
Use two observers — one with the pre-render margin, one with none — so each element's state is known precisely, and keep one TaskController per pending element.
type Priority = 'user-blocking' | 'user-visible' | 'background';
interface Scheduler {
postTask<T>(cb: () => T, opts?: { priority?: Priority; signal?: AbortSignal }): Promise<T>;
}
declare const scheduler: Scheduler | undefined;
declare class TaskController extends AbortController {
constructor(init?: { priority?: Priority });
setPriority(p: Priority): void;
}
const pending = new WeakMap<Element, TaskController>();
const rendered = new WeakSet<Element>();
function queueRender(el: HTMLElement, priority: Priority): void {
if (rendered.has(el)) return;
const existing = pending.get(el);
if (existing) { existing.setPriority(priority); return; } // just re-prioritise
if (typeof scheduler === 'undefined') { // fallback: plain task
setTimeout(() => { if (!rendered.has(el)) { renderChart(el); rendered.add(el); } }, 0);
return;
}
const tc = new TaskController({ priority });
pending.set(el, tc);
scheduler.postTask(() => {
pending.delete(el);
if (!el.isConnected) return;
renderChart(el);
rendered.add(el);
}, { signal: tc.signal }).catch(() => { /* AbortError: element left */ });
}
function cancelRender(el: Element): void {
pending.get(el)?.abort();
pending.delete(el);
}
// Observer 1: the pre-render margin. Enter → background; leave → cancel.
const near = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) queueRender(e.target as HTMLElement, 'background');
else cancelRender(e.target);
}
}, { rootMargin: '800px 0px' });
// Observer 2: the viewport itself. Enter → raise to user-visible.
const inView = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) queueRender(e.target as HTMLElement, 'user-visible');
});
document.querySelectorAll<HTMLElement>('.widget').forEach((w) => { near.observe(w); inView.observe(w); });
On load, the three charts actually on screen are queued at user-visible and render first; the five in the margin wait at background. A filter click runs as an input handler between tasks. If the user scrolls past a margin chart before it renders, its task is aborted. If they scroll towards it, the second observer raises its priority and it jumps ahead of the other background renders.
Priority Inversion and Other Traps
Priorities help only if everything important is labelled correctly. Three patterns undo them:
Everything is user-visible. If every task is posted at the default priority, the scheduler degenerates to FIFO. Be deliberate: most observer-triggered work outside the viewport is background.
user-blocking for rendering. It is tempting to mark "the visible chart" as user-blocking. That priority is for work that must happen before the user can continue interacting, such as responding to the input itself. Rendering content is user-visible; overusing user-blocking starves genuine input responses in your own code.
Long single tasks. Priority decides order, not preemption. A 40 ms chart render at background priority, once started, still blocks input for 40 ms. Combine priorities with yielding inside the task when individual renders are long.
Edge Cases
Safari. scheduler.postTask is not available in Safari at the time of writing. The fallback above degrades to FIFO setTimeout, losing ordering but keeping responsiveness. A small priority queue drained by MessageChannel tasks can restore ordering if Safari traffic matters.
Detached elements. The WeakMap and WeakSet mean a widget removed from the DOM does not keep its controller or bookkeeping alive; the isConnected check prevents rendering into detached nodes.
Route changes. Unobserve and cancel on unmount. Aborting a controller whose task already ran is a no-op, so blanket cancellation is safe.
Two observers per element. The extra observer is cheap — one instance for all widgets — and avoids having to compute "is this in the real viewport" from rootBounds and boundingClientRect inside a single margin observer.
Verification Steps
- Record a trace on load: chart renders on screen should appear before margin renders, with the click handler interleaved.
- Click a filter immediately after load and measure its interaction latency with the Interactions track.
- Fling past the margin charts and confirm their render functions never run (a log or breakpoint).
- Scroll towards a margin chart and confirm it renders before other margin charts.
- Test in Safari to confirm the fallback path works.
Common Mistakes to Avoid
- Not handling the rejection. Aborted
postTaskpromises reject withAbortError; unhandled, they pollute error monitoring. - One controller for all elements. Aborting it cancels everything; keep one per element.
- Forgetting that priorities do not preempt. Split long tasks as well as ordering them.
- Ignoring the fallback. Without it, the feature breaks entirely where the API is missing.
FAQ
What is the difference between AbortController and TaskController here?
Both can cancel a task. TaskController additionally has setPriority(), which changes the priority of every task posted with its signal — exactly what is needed when an element moves from the margin into the viewport.
Does postTask run in the same frame as the observer callback?
No. It queues a new task. For IntersectionObserver that makes no visible difference, since the callback already runs after paint. For ResizeObserver it moves the work out of the rendering steps into a later task.
Can postTask replace requestIdleCallback?
For most purposes, background-priority postTask works well and adds cancellation. Idle callbacks remain useful when you want a deadline to bound work within idle periods.
Is there a delay option?
Yes. postTask accepts a delay in milliseconds, which combined with a priority is a cleaner alternative to setTimeout for deferring work such as a dwell-time check.
Related
- Deferring Non-Urgent Observer Work with requestIdleCallback — idle-time alternative
- Keeping Observer Callbacks Under the INP Budget — verifying the result in the field
- Debouncing Chart Redraws on Container Resize — the resize side of chart rendering