Wrapping IntersectionObserver callbacks in requestAnimationFrame eliminates redundant frame requests and ensures every DOM mutation happens during a single, well-timed paint cycle rather than forcing repeated synchronous layouts.
Problem / Scenario Context
Developers commonly attach visual update logic — class toggles, style mutations, counter increments — directly inside an IntersectionObserver callback. This works for sparse visibility changes, but as described in the IntersectionObserver API Deep Dive, the browser delivers callbacks in batches during the rendering update steps. When 50 grid items cross a visibility threshold simultaneously during a fast scroll, the callback receives all 50 entries at once. If each entry independently calls requestAnimationFrame, the browser queues 50 separate frame requests — most of them redundant — and the first DOM write in any one of them will force a style recalculation that invalidates the clean layout snapshot every subsequent read depends on.
The symptom is visible jank: the browser drops below 60 fps, DevTools shows red "Forced Reflow" markers on the main thread flame chart, and threshold array callbacks that previously fired cleanly now stall mid-scroll.
Mechanics Explanation
The browser's rendering pipeline runs in this order per frame: JavaScript → Style → Layout → Paint → Composite. IntersectionObserver callbacks are dispatched during the "notify intersection observers" step, which sits between Layout and Paint. They are near a paint boundary but are not themselves frame-gated — multiple callbacks can fire in rapid succession within a single frame interval.
The breakdown happens at the read/write boundary. When callback code reads geometry (entry.boundingClientRect, entry.intersectionRatio) and then writes to the DOM (mutating a style property or toggling a class), the browser must perform a forced synchronous layout to guarantee the geometry read reflects the current DOM state. If a second callback then reads geometry again, a second forced layout is triggered. Each forced layout costs 2–6 ms on a mid-range device, and 50 of them in one frame costs the entire 16.6 ms budget.
Calling requestAnimationFrame once for the entire batch solves this by deferring all DOM writes to the start of the next paint cycle, where the browser has already committed a clean layout snapshot. The key constraint is the guard: only one rAF must be queued per frame, regardless of how many observer entries arrive.
Comparison: Unsynced vs. Synced Callback Behaviour
| Scenario | Frame cost | Forced layouts | rAF calls queued |
|---|---|---|---|
10 entries, each calls rAF independently |
~3–6 ms overhead | Up to 10 | 10 (9 wasted) |
50 entries, each calls rAF independently |
~15–30 ms overhead | Up to 50 | 50 (49 wasted) |
50 entries, single buffered rAF dispatcher |
~0.1 ms overhead | 0 | 1 |
| 0 entries (no threshold crossing) | 0 ms | 0 | 0 |
Frame-Pipeline Diagram
The following diagram shows where IntersectionObserver callbacks land in the browser's per-frame rendering pipeline, and how the buffered rAF dispatcher shifts DOM mutations to the correct write phase.
Minimal Reproducible Example
This 25-line snippet isolates the problem. Paste it into DevTools console on any page with multiple visible elements to observe duplicate rAF scheduling:
// Demonstrates the problem: each entry schedules its own rAF
const problematic = (entries: IntersectionObserverEntry[]): void => {
entries.forEach((entry) => {
// BAD: called once per entry — up to N rAF calls per observer batch
requestAnimationFrame(() => {
entry.target.classList.toggle("visible", entry.isIntersecting);
// Reading layout geometry here forces a synchronous reflow:
const h = (entry.target as HTMLElement).offsetHeight;
console.log("height after write:", h);
});
});
};
const obs = new IntersectionObserver(problematic, { threshold: 0.1 });
document.querySelectorAll(".item").forEach((el) => obs.observe(el));
// JS fallback (no types):
// const problematic = (entries) => {
// entries.forEach(entry => {
// requestAnimationFrame(() => { /* ... */ });
// });
// };
Production-Safe Solution
The fix uses a single Set buffer and a guarded rAF call so that exactly one frame update is scheduled per animation frame, regardless of how many entries arrive. Reads happen before writes within that frame.
interface EntryMeasurement {
id: string;
ratio: number;
isIntersecting: boolean;
}
const observerQueue = new Set<IntersectionObserverEntry>();
let rafId: number | null = null;
const scheduler = (): void => {
rafId = null; // allow the next batch to schedule a new frame
// Phase 1 — DOM reads (no writes yet; layout snapshot is clean)
const measurements: EntryMeasurement[] = Array.from(observerQueue).map(
(e) => ({
id: (e.target as HTMLElement).dataset.id ?? "",
ratio: e.intersectionRatio,
isIntersecting: e.isIntersecting,
})
);
observerQueue.clear(); // discard stale references immediately
// Phase 2 — DOM writes (all reads are complete; no forced reflow)
measurements.forEach(({ id, ratio, isIntersecting }) => {
const el = document.querySelector<HTMLElement>(`[data-id="${id}"]`);
if (!el) return;
el.classList.toggle("in-view", isIntersecting);
el.style.setProperty("--intersect-ratio", String(ratio));
});
};
// One rAF per frame regardless of entry volume
const syncCallback = (entries: IntersectionObserverEntry[]): void => {
entries.forEach((e) => observerQueue.add(e));
if (!rafId) rafId = requestAnimationFrame(scheduler);
};
const observer = new IntersectionObserver(syncCallback, {
threshold: [0, 0.1, 0.5, 1.0],
});
// Cleanup — call on route change or component unmount
export const teardown = (): void => {
observer.disconnect();
if (rafId !== null) cancelAnimationFrame(rafId);
observerQueue.clear();
};
// Plain JS equivalent (no TypeScript):
// const observerQueue = new Set();
// let rafId = null;
// const scheduler = () => { rafId = null; /* reads then writes */ };
// const syncCallback = (entries) => {
// entries.forEach(e => observerQueue.add(e));
// if (!rafId) rafId = requestAnimationFrame(scheduler);
// };
SSR / hydration note: During server-side rendering, requestAnimationFrame is not available. Guard with typeof requestAnimationFrame !== 'undefined' or defer observer initialisation to useLayoutEffect / onMounted so callbacks never fire before the DOM is interactive. For long-running single-page applications, the observer lifecycle and memory management guidance covers additional teardown strategies.
Background-tab throttling: Browsers suspend rAF when a tab is hidden. Attach a visibilitychange listener: when document.hidden is true, flush the queue synchronously and cancel the pending rAF to avoid stale state accumulating while the tab is inactive.
Verification Steps
- Open DevTools Performance panel, start a recording, scroll rapidly across many observed elements, then stop.
- In the flame chart, filter for
Layoutevents. With the unsynced approach you will see repeatedForced Reflow(purple) markers; after applying the buffered dispatcher, these disappear. - Switch to the Rendering panel and enable Frame Rendering Stats (or the FPS meter). Confirm the frame rate stays at or near 60 fps during fast scroll.
- In the Console, add
console.count("rAF fired")insidescheduler. Across a batch of 50 observed elements, the counter should increment by exactly 1, not 50. - Check the Memory panel after navigation away from the observed page. Confirm no detached DOM nodes remain in the heap snapshot — evidence that
observer.disconnect()andcancelAnimationFrame()ran correctly.
Common Mistakes to Avoid
- Calling
requestAnimationFrameinside theforEachloop. This is the root cause of the problem. Move therAFcall outside the loop and protect it with therafIdguard. - Reading
entry.boundingClientRectafter a DOM write within the same rAF tick. Even inside arAFcallback, a write followed by a read triggers a forced synchronous layout. Always exhaust all reads before any write. - Skipping
cancelAnimationFrameon teardown. A pendingrAFthat executes after a component unmounts will attempt to query a detached DOM tree. Theteardownfunction must cancel the pending frame ID before the observer disconnects. See preventing memory leaks in long-running observers for a complete teardown checklist. - Using
queueMicrotaskinstead ofrAF. Microtasks run before the browser commits to a paint frame, so they provide no protection against forced layouts within the same rendering cycle.
Related
- How IntersectionObserver Threshold Works in Practice
- Preventing Memory Leaks in Long-Running Observers
- Reducing Layout Thrashing with ResizeObserver
↑ Back to IntersectionObserver API Deep Dive