An IntersectionObserver callback runs in its own queued task after the frame whose geometry it describes has been painted — so anything you change in response can only appear in the following frame.

Problem / Scenario Context

A product page fades in its hero image when it scrolls into view. The CSS starts the image at opacity: 0 and an observer adds a .visible class. On fast devices it looks fine. On a mid-range Android phone, testers report a flash: the empty slot scrolls into view, stays blank for a beat, and only then starts fading. Nobody can reproduce it on a laptop.

The flash is not a bug in the observer, and no amount of threshold tuning removes it. It is a consequence of where the callback sits in the event loop, which the Rendering Pipeline & Observer Timing topic maps for every observer family. Here we look only at IntersectionObserver.

Mechanics Explanation

The HTML event loop processes one task, drains the microtask queue, and then — if it is time to render — runs the update the rendering steps. Among those steps is "run the update intersection observations steps", which walks every observer, computes the intersection rectangle for each target against its root (expanded or shrunk by rootMargin), and compares the resulting ratio with the last recorded threshold index.

When a crossing is detected, the browser creates an IntersectionObserverEntry, appends it to the observer's internal queue, and queues a task to notify observers. It does not call your callback there and then. The rendering steps continue: paint happens, the frame is presented, and at some later point the event loop picks up the notification task and invokes the callback with every entry that accumulated.

Three facts follow directly:

  1. The frame containing the crossing has already been painted by the time you hear about it.
  2. The task competes with every other task — input handlers, timers, network callbacks. On a congested main thread it can wait tens of milliseconds.
  3. Entries are batched per observer, so if two frames of crossings happen before the task runs, you get both in one call.

From Crossing to CallbackFour boxes left to right. Layout finishes during the rendering steps. The intersection step compares the new ratio with the previous threshold index and, on a crossing, queues an entry. Paint presents the frame with the old styles. Finally, a separate task runs the callback, whose DOM writes can only appear in the next frame.Layoutgeometry for thisframe is finalIntersection stepratio crosses athreshold; entry queuedPaintframe shown with theold classesCallback taskyour writes land in thenext frameThe gap between the third and fourth box is where the blank flash lives — and it grows with main-thread congestion.

Comparison Table: Delivery Delay by Condition

Condition Typical delay from computation to callback Visible effect
Idle main thread, 60 Hz under 1 ms after paint none — change appears next frame
Busy main thread, long task queued 20–200 ms blank slot or late fade-in
120 Hz display half a frame shorter effect halved but still present
Background tab callback never runs until visible burst of stale entries on return
Low Power Mode (30 Hz cap) up to 33 ms coarse, stepped transitions

Minimal Reproducible Example

This snippet makes the delay visible by blocking the main thread right after each paint, so the notification task is forced to wait.

TypeScript
const img = document.querySelector<HTMLImageElement>('.hero')!;

const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    const lag = performance.now() - e.time;             // computation → delivery
    console.log(`isIntersecting=${e.isIntersecting} lag=${lag.toFixed(1)}ms`);
    if (e.isIntersecting) img.classList.add('visible');
  }
});
io.observe(img);

// Simulate a congested page: a 120 ms task queued every 200 ms.
setInterval(() => {
  const end = performance.now() + 120;
  while (performance.now() < end) { /* busy */ }
}, 200);

Scroll the hero into view and the console shows lag values that jump well past 100 ms whenever the busy task gets in first. The fade is late by exactly that amount.

Delivery Lag Under Main-Thread LoadHorizontal bars showing measured delivery lag for an intersection callback. With an idle thread the lag is well under a millisecond. With a fifty millisecond task queued it is about fifty milliseconds. With a one hundred and twenty millisecond task it rises to about one hundred and twenty. In a hidden tab the callback waits until the tab is shown.entry.time → callback, measured on a mid-range phoneidle main threadunder 1 ms50 ms task queuedabout 50 ms120 ms task queuedabout 120 mshidden tabwaits until visible

Production-Safe Solution

You cannot move the callback earlier, so the fix is to make sure nothing depends on it arriving before the element is visible. Two techniques combine well: start the reveal before the element reaches the viewport, and make the default state safe if the callback is late.

TypeScript
interface RevealOptions {
  lead: string;          // how far ahead of the viewport to start, e.g. '200px'
  className: string;
}

export function observeReveal(
  targets: Iterable<Element>,
  { lead, className }: RevealOptions,
): () => void {
  // 1. Pre-arm: expand the root so crossings happen before the element is on screen.
  const io = new IntersectionObserver(
    (entries, obs) => {
      for (const e of entries) {
        if (!e.isIntersecting) continue;
        e.target.classList.add(className);
        obs.unobserve(e.target);              // one-shot: no further tasks for this target
      }
    },
    { rootMargin: `${lead} 0px ${lead} 0px`, threshold: 0 },
  );
  for (const t of targets) io.observe(t);
  return () => io.disconnect();
}

// 2. Safe default: the CSS only hides elements once JS has confirmed it is running.
document.documentElement.classList.add('js-reveal');
const stop = observeReveal(document.querySelectorAll('.reveal'), { lead: '200px', className: 'visible' });
CSS
/* Without .js-reveal (no JS, or JS failed), content is simply visible. */
.js-reveal .reveal:not(.visible) { opacity: 0; transform: translateY(12px); }
.reveal { transition: opacity 240ms ease, transform 240ms ease; }

The 200-pixel lead converts "late by one congested task" into "early by a scroll distance", which is almost always enough. On the slowest devices, the worst case is that the transition runs while the element is already on screen — a fade rather than a blank slot.

For work that genuinely must land in the same frame as a geometry change, IntersectionObserver is the wrong tool; a ResizeObserver callback runs before paint and can.

Edge Cases: Batching, Ordering and Multiple Observers

A few details of the delivery model catch people out once a page has more than one observer.

Several observers share one notification task. The spec queues a single task that notifies every observer with pending entries, in the order the observers were created. If a lazy-image observer and an analytics observer both see crossings in the same frame, they run back to back in that task — and a slow analytics callback delays the image swap that follows it. Creation order is therefore a (weak) priority lever: construct the observer whose callback affects pixels first.

The first delivery is not a crossing. Calling observe() schedules an initial notification for the next rendering opportunity, reporting the current state whether or not a threshold was crossed. Code that treats every entry as "just became visible" will fire for elements that were visible at load, which is usually right for reveals and wrong for impression counting. Check isIntersecting and keep your own per-target state.

unobserve() inside the callback does not cancel entries already queued. If a target crossed twice before the task ran, both entries are in the array you are iterating. Unobserving on the first one does not remove the second from the array; it only prevents future ones. Guard with a Set of handled targets when the effect must happen exactly once.

Timestamps are comparable across observers. entry.time uses the document's time origin, the same clock as performance.now() and PerformanceObserver entries. That makes it possible to line up an intersection with a long task or a layout shift in the same trace without guesswork.

One Notification Task, Many ObserversA stacked view of a single notification task after paint. The lazy image observer created first runs first and swaps the image source. The analytics observer created second runs next and records an impression. The reveal observer created third runs last and adds a class. A slow callback in the middle delays everything after it in the same task.One task after paint — observers run in creation order1. lazy-image observerCreated first, so it swaps src before anything else in the task runs.2. analytics observerCreated second. A slow callback here delays every observer below it.3. reveal observerCreated third; its class change still paints in the next frame, just later.

Verification Steps

  • Record a Performance trace while scrolling and find the task labelled with your callback; it should sit after a Paint event, never inside the rendering steps.
  • Log performance.now() - entry.time in the callback on a throttled CPU (DevTools → Performance → CPU 4× slowdown) and confirm the lag is absorbed by the lead distance.
  • Scroll fast with the network throttled and check that no reveal target is ever visible at opacity: 0 for more than a frame.
  • Disable JavaScript and confirm every .reveal element is visible — the safe default works.

Common Mistakes to Avoid

  • Trying to fix the flash with a higher threshold. A higher threshold makes the crossing happen later, which lengthens the blank period.
  • Using requestAnimationFrame inside the callback to "catch up". It schedules the write for the next frame, which is where it was going to land anyway, and adds one more task.
  • Hiding content in the default stylesheet. If the script fails to load, the content never appears.
  • Leaving one-shot targets observed. Every crossing on an already-revealed element queues another notification task for nothing.

FAQ

Is the IntersectionObserver callback a microtask?

No. It is delivered by a queued task, not at a microtask checkpoint. That is why it can be delayed by other tasks and why it never runs in the middle of your own synchronous code.

Does a higher-priority task queue make IntersectionObserver faster?

Browsers treat the notification task as normal priority and do not expose a way to raise it. Scheduling your other work at lower priority, for example with scheduler.postTask and a background priority, gives the notification task a better chance of running promptly.

What is the difference between entry.time and the time my callback runs?

entry.time is when the browser computed the intersection during the rendering steps. The callback runs later, in a task. The difference is the delivery lag, and it is a direct measure of how busy the main thread was.

Why do I sometimes get two entries for the same target in one call?

Because entries are queued per observer and delivered in one batch. If the target crossed in and out of a threshold in two consecutive frames before the task ran, both entries are present, in order. Always use the last entry for a target when you only care about the current state.


↑ Back to Rendering Pipeline & Observer Timing