Push non-urgent observer work — analytics, prefetching, cache warming — onto a queue, drain it in requestIdleCallback while deadline.timeRemaining() allows, set a timeout so a busy page still drains eventually, and flush whatever remains on visibilitychange to hidden.
Problem / Scenario Context
A news homepage fires an impression event for every headline that becomes 50% visible, and prefetches the article HTML for headlines that stay visible for a second. Both are driven by an IntersectionObserver. Profiling shows the impression code serialising a JSON payload and calling navigator.sendBeacon directly in the callback, sometimes a dozen times per batch, and the prefetch code creating <link rel="prefetch"> elements in the same task. Neither is visible to the user, yet together they add 20–30 ms to every scroll batch — right when users are tapping headlines.
This is the "idle" category in Scheduling Observer Work Off the Critical Path: work whose timing the user never perceives, as long as it eventually happens.
Mechanics Explanation
requestIdleCallback(cb, { timeout }) asks the browser to run cb during an idle period — time between frames when there is no rendering or input to do, or a longer stretch when the page is not animating at all. The callback receives an IdleDeadline:
timeRemaining()— milliseconds left in the current idle period, at most 50. Work should stop when it approaches zero.didTimeout—trueif the callback is running because thetimeoutexpired rather than because the page became idle. In that case you are running in a normal task and should do a bounded amount of work.
Idle periods are frequent on calm pages and rare on busy ones. A page with a continuous animation loop may have only fragments of idle time between frames; a page doing heavy work may have none. That is why timeout is essential — and why work that must not be lost needs a final flush when the page is being hidden, since idle callbacks do not run in a page that is being unloaded.
Comparison Table: Where Deferred Work Can Go
| Mechanism | Runs when | Lost on page hide? | Good for |
|---|---|---|---|
| In the observer callback | immediately | no | only frame-critical work |
requestIdleCallback without timeout |
whenever idle — maybe never | yes | truly optional work |
requestIdleCallback with timeout |
idle, or at timeout | yes, unless flushed | analytics, prefetch |
scheduler.postTask({ priority: 'background' }) |
after higher priorities | yes, unless flushed | similar; cancellable |
setTimeout(fn, 1000) |
after a delay, regardless of load | yes | crude batching |
navigator.sendBeacon on visibilitychange |
as page hides | no — survives unload | final flush |
Minimal Reproducible Example
new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.intersectionRatio < 0.5) continue;
const id = (e.target as HTMLElement).dataset.articleId!;
navigator.sendBeacon('/i', JSON.stringify({ id, t: Date.now() })); // per entry, in the callback
const link = Object.assign(document.createElement('link'), { rel: 'prefetch', href: `/a/${id}` });
document.head.append(link); // DOM work, in the callback
}
}, { threshold: [0.5] }).observe(...document.querySelectorAll('[data-article-id]'));
A trace shows each batch's callback growing with the number of headlines, with a beacon call per entry.
Production-Safe Solution
type Job = () => void;
class IdleQueue {
private jobs: Job[] = [];
private handle: number | null = null;
constructor(private readonly timeoutMs = 2000) {
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') this.flush(); // never lose work
});
}
push(job: Job): void {
this.jobs.push(job);
if (this.handle === null) this.schedule();
}
private schedule(): void {
const ric = window.requestIdleCallback ?? ((cb: IdleRequestCallback) =>
window.setTimeout(() => cb({ didTimeout: true, timeRemaining: () => 5 }), 200));
this.handle = ric((deadline) => this.drain(deadline), { timeout: this.timeoutMs });
}
private drain(deadline: IdleDeadline): void {
this.handle = null;
// On timeout, do a bounded chunk; otherwise use the idle budget.
const limit = deadline.didTimeout ? 5 : Infinity;
let n = 0;
while (this.jobs.length && (deadline.timeRemaining() > 2 || (deadline.didTimeout && n < limit))) {
this.jobs.shift()!();
n++;
}
if (this.jobs.length) this.schedule();
}
flush(): void {
while (this.jobs.length) this.jobs.shift()!();
}
}
const idle = new IdleQueue();
const pendingImpressions: string[] = [];
function queueImpression(id: string): void {
pendingImpressions.push(id);
if (pendingImpressions.length === 1) {
idle.push(() => {
// One beacon per batch, not per headline.
navigator.sendBeacon('/i', JSON.stringify({ ids: pendingImpressions.splice(0) }));
});
}
}
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.intersectionRatio < 0.5) continue;
const id = (e.target as HTMLElement).dataset.articleId!;
queueImpression(id); // the callback only records ids
idle.push(() => prefetch(`/a/${id}`));
}
}, { threshold: [0.5] });
function prefetch(href: string): void {
if (document.head.querySelector(`link[rel="prefetch"][href="${CSS.escape(href)}"]`)) return;
document.head.append(Object.assign(document.createElement('link'), { rel: 'prefetch', href }));
}
The callback now only pushes closures onto a queue. Impressions are coalesced into one beacon per drain; prefetches are deduplicated. If the user closes the tab before the queue drains, the visibilitychange flush sends what remains — sendBeacon is designed to survive page unload.
What Belongs in the Idle Queue
Not all "non-visual" work is equal. A quick classification keeps the queue from becoming a dumping ground:
- Idle queue: impression and viewability beacons, prefetching of likely next pages, warming caches (decoding a JSON blob into a lookup table), logging.
- User-visible priority, not idle: hydrating a widget the user may interact with soon, rendering below-the-fold content that will scroll in within a second — use scheduler.postTask with
user-visibleinstead. - Never deferred: swapping image sources, toggling visibility classes, pausing off-screen video — cheap, visible, or both.
Prefetching deserves a special note: it competes for network bandwidth, not just the main thread. Idle-time scheduling protects the main thread but does not stop a burst of prefetches from slowing an image the user is waiting for. Limit concurrent prefetches, and skip them when navigator.connection?.saveData is true.
Edge Cases
Safari. requestIdleCallback shipped late in Safari and may be missing in older versions still in use. The fallback above uses a 200 ms timeout-based shim that reports a small timeRemaining, which is conservative but correct.
Background tabs. Browsers throttle timers and may run idle callbacks rarely in hidden tabs. Because the queue flushes on hidden, work queued before the tab was hidden is sent at that moment rather than waiting.
bfcache. A page restored from the back/forward cache resumes with its queue intact. Pages that flush on hidden start with an empty queue; that is the desired outcome. Avoid unload listeners, which disqualify pages from the bfcache in several browsers.
Very long queues. If jobs accumulate faster than idle time drains them — a long scroll through thousands of items — cap the queue and coalesce. For impressions, keep a Set of ids rather than a job per id.
Verification Steps
- Record a trace while scrolling: the observer callback should shrink to a fraction of a millisecond per batch.
- Watch the Network panel: impressions should arrive as a few batched beacons, not one per headline.
- Close the tab mid-scroll with a logging endpoint and confirm the final flush arrives.
- Run a CPU-heavy animation on the page and confirm the queue still drains within the timeout.
- Test with Save-Data enabled and confirm prefetches are skipped.
Common Mistakes to Avoid
- Omitting the timeout. On busy pages, idle callbacks may never run.
- Relying on idle callbacks during unload. They will not run; flush on
visibilitychangeinstead. - Doing unbounded work when
didTimeoutis true. You are no longer in idle time and can block input. - Deferring work the user is about to see. Idle priority is the lowest; visible work belongs higher.
FAQ
How long can a single idle callback run?
timeRemaining never reports more than 50 ms, and in the gaps between frames it is often only a few milliseconds. Check it in your loop and stop when it runs low, rescheduling the rest.
Is requestIdleCallback the same as scheduler.postTask with background priority?
They are similar in intent. postTask background tasks run when higher-priority work is done but are not tied to idle periods and get no deadline; idle callbacks get a deadline and are more conservative. Either works for analytics; postTask adds cancellation through TaskController.
Why flush on visibilitychange rather than pagehide?
visibilitychange to hidden fires in more situations, including switching apps on mobile, where pagehide may never follow because the page is later discarded. It is the last event you can reliably count on.
Does sendBeacon block the page?
No. It queues the request with the browser and returns immediately; delivery happens asynchronously and continues even if the page unloads.
Related
- Yielding from Observer Callbacks with scheduler.yield — for work that should not wait for idle
- Tracking Ad Visibility for Analytics Compliance — the impression logic itself
- Impression Tracking for Product Carousels — batching impressions in a carousel