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:
- The frame containing the crossing has already been painted by the time you hear about it.
- The task competes with every other task — input handlers, timers, network callbacks. On a congested main thread it can wait tens of milliseconds.
- Entries are batched per observer, so if two frames of crossings happen before the task runs, you get both in one call.
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.
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.
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.
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' });
/* 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.
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.timein 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: 0for more than a frame. - Disable JavaScript and confirm every
.revealelement 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
requestAnimationFrameinside 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.
Related
- ResizeObserver and the Update-the-Rendering Steps — the observer that does run before paint
- Why Observer Callbacks Lag One Frame Behind — diagnosing the visible symptom
- Syncing Observer Callbacks with requestAnimationFrame — when an extra frame hop is worth it
↑ Back to Rendering Pipeline & Observer Timing