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.
  • didTimeouttrue if the callback is running because the timeout expired 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.

Idle Periods Between FramesA timeline across three frames. Each frame has rendering work, and between them there are idle gaps of varying length. The idle queue drains a few items in each gap, stopping when timeRemaining approaches zero. A long gap after scrolling stops lets the rest of the queue drain.Scrolling, then settlingrenderingframe 1frame 2frame 3idle queue21drain remaining 90ms10ms20ms30ms40ms50ms60ms

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

TypeScript
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

TypeScript
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()!();
  }
}
TypeScript
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.

From Callback to Idle Queue to BeaconFour boxes. The observer callback only records article identifiers and pushes jobs. The idle queue waits for idle time or its timeout. The drain coalesces impressions into a single beacon and deduplicates prefetch links. On page hide, a flush sends anything still pending.IO callbackrecord ids, push jobsIdle queuewaits for idle ortimeoutDrainone beacon; dedupedprefetchPage hideflush the rest viasendBeacon

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-visible instead.
  • Never deferred: swapping image sources, toggling visibility classes, pausing off-screen video — cheap, visible, or both.

Sorting Observer Work by UrgencyA grid of common observer-triggered tasks and where each belongs. Swapping an image source runs in the callback. Hydrating a widget runs at user-visible priority. Rendering content below the fold runs at user-visible priority. Impression beacons, prefetching and cache warming go to the idle queue.In the callbackuser-visible taskIdle queueSwap image srcyesnonoHydrate a widgetnoyesnoImpression beaconnonoyesPrefetch next pagenonoyesWarm a cachenonoyes

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 visibilitychange instead.
  • Doing unbounded work when didTimeout is 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.


↑ Back to Scheduling Observer Work Off the Critical Path