Replace the scroll event listener with an IntersectionObserver sentinel: it delivers intersection callbacks off the critical rendering path, so the compositor thread stays unblocked and the page holds 60 fps even during aggressive scrolling.
Problem / Scenario Context
Infinite scroll is the most visible performance battleground in modern list UIs. The traditional approach — attaching a scroll listener to window and calling getBoundingClientRect() or scrollTop to detect "near the bottom" — fires tens of times per second during fast scroll. Each call can trigger a forced synchronous layout if it follows a DOM write in the same frame, and each DOM append adds more pressure. The result is the telltale jank: frames that stall visibly, laggy touch response on mobile, and Long Tasks that lock out user input.
The root of this problem sits squarely in Infinite Scroll & Pagination patterns, and its solution is the sentinel element strategy: place an empty element immediately after your last list item, observe it with IntersectionObserver, and load the next batch only when the browser reports an intersection. The browser handles the geometry math internally — no getBoundingClientRect(), no layout reads, no forced reflow.
Mechanics Explanation
Scroll jank has three compounding causes in a naive infinite scroll implementation:
Synchronous layout reads during scroll. When your scroll handler calls element.scrollTop, offsetHeight, or getBoundingClientRect() after any style or DOM mutation has occurred, the browser must immediately recalculate layout to return a fresh value. This is a forced synchronous reflow. It blocks the main thread until the full layout tree is invalidated and rebuilt — often taking 10–50 ms on a populated document, blowing the 16 ms frame budget entirely.
Unthrottled DOM appends. Appending nodes mid-frame on the main thread causes a second layout pass during the same tick. Two layout passes in 16 ms is impossible to recover from without dropping frames.
Unqueued concurrent fetches. Scroll events can fire faster than fetch can complete. Without a guard, multiple in-flight requests each succeed and each trigger a DOM append — causing duplicate items, sentinel mis-positioning, and a broken page size sequence.
IntersectionObserver sidesteps all three. The browser computes intersection geometry during its own layout phase, batches changes, and delivers a single callback after the frame is committed. Your handler never runs inside a frame being painted. Pairing the callback with requestAnimationFrame ensures any resulting DOM write lands at the very start of the next frame, giving the browser the full 16 ms budget for that update.
For a deeper look at how the browser schedules these callbacks relative to the rendering pipeline, see Syncing Observer Callbacks with requestAnimationFrame.
Comparison Table: Scroll Listener vs. IntersectionObserver Sentinel
| Concern | scroll listener approach |
IntersectionObserver sentinel |
|---|---|---|
| Layout reads per scroll event | 1 forced reflow per tick | Zero — browser handles geometry |
| Main-thread blocking | Yes — blocks compositor | No — callback fires post-frame |
| Fires per fast scroll burst | 20–80 times / second | Once when sentinel enters root bounds |
requestAnimationFrame needed for safety |
Yes, but scroll still fires first | Callback already safe; rAF for DOM write only |
| Cleanup surface | removeEventListener on mount/unmount |
observer.disconnect() |
| Mobile momentum scroll safety | Needs extra debounce | Handled natively by rootMargin buffer |
Scroll Rendering Pipeline: Where Callbacks Slot In
The SVG below shows the browser's per-frame rendering pipeline. A scroll listener fires in the JavaScript step and can trigger forced layout before the engine has committed the previous frame's paint. The IntersectionObserver callback fires after commit, outside the frame's critical path, keeping the compositor free.
Minimal Reproducible Example
This 28-line snippet isolates the core problem: a scroll listener that reads layout and appends nodes in the same tick, causing forced reflows.
// ❌ Jank-inducing pattern — do not ship this
const list = document.querySelector<HTMLUListElement>('#list')!;
window.addEventListener('scroll', () => {
// Forced synchronous layout read after potential DOM write
const { bottom } = list.getBoundingClientRect(); // reflow!
if (bottom < window.innerHeight + 200) {
const item = document.createElement('li');
item.textContent = 'New item';
list.appendChild(item); // write triggers another reflow next tick
}
});
// Plain JS equivalent:
// window.addEventListener('scroll', function() {
// var rect = list.getBoundingClientRect();
// if (rect.bottom < window.innerHeight + 200) {
// var item = document.createElement('li');
// item.textContent = 'New item';
// list.appendChild(item);
// }
// });
Open Chrome DevTools > Performance, record 3 seconds of fast scroll, and you will see red "Long Task" markers and Layout events that overlap with Scroll events in the flame chart. That overlap is the forced synchronous reflow.
Production-Safe Solution
The following implementation replaces the scroll listener with an IntersectionObserver, gates all DOM writes behind requestAnimationFrame, queues fetches through AbortController, and provides explicit teardown.
interface InfiniteScrollOptions {
sentinelSelector: string;
rootMargin?: string;
onLoadMore: (signal: AbortSignal) => Promise<HTMLElement[]>;
}
function createInfiniteScroll(opts: InfiniteScrollOptions): () => void {
const { sentinelSelector, rootMargin = '200px 0px', onLoadMore } = opts;
const sentinel = document.querySelector<HTMLElement>(sentinelSelector);
if (!sentinel) return () => {};
let isLoading = false;
let abortController = new AbortController();
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (!entry.isIntersecting || isLoading) return;
isLoading = true;
abortController.abort(); // cancel any in-flight request
abortController = new AbortController();
// Gate DOM mutation to the next animation frame
requestAnimationFrame(async () => {
try {
const nodes = await onLoadMore(abortController.signal);
// Batch all appends in one rAF to avoid mid-frame reflows
requestAnimationFrame(() => {
const fragment = document.createDocumentFragment();
nodes.forEach(n => fragment.appendChild(n));
sentinel.before(fragment); // insert before sentinel keeps it at bottom
isLoading = false;
});
} catch (err) {
if ((err as Error).name !== 'AbortError') {
console.error('Infinite scroll fetch failed:', err);
}
isLoading = false;
}
});
},
{ rootMargin, threshold: 0 }
);
observer.observe(sentinel);
// Return cleanup function — call on component unmount / route change
return () => {
observer.disconnect();
abortController.abort();
};
}
// Plain JS equivalent (remove type annotations):
// function createInfiniteScroll(opts) { ... }
Key decisions explained inline:
rootMargin: '200px 0px'— the sentinel triggers 200 px before it enters the visible viewport, masking network latency so the user never sees a blank gap.isLoadingguard — prevents the observer from firing a second load while the first append is still in progress.- Double
requestAnimationFrame— the outer rAF ensures the fetch starts in a clean frame; the inner rAF ensures the DOM append happens at the very beginning of the next frame after data arrives, giving the browser the full 16 ms budget for the paint. document.createDocumentFragment()— batches all node appends into a single layout operation rather than one reflow per node.sentinel.before(fragment)— keeps the sentinel permanently at the bottom of the list without manually repositioning it.
For framework lifecycle integration, bind the returned cleanup function to useEffect's return value (React) or onUnmounted (Vue) to prevent memory leaks in long-running observers.
Verification Steps
After deploying the IntersectionObserver-based solution, confirm it is working correctly:
- Performance panel baseline: Record a 3-second fast-scroll session. The flame chart must show no
Layoutevents overlapping with the JavaScript band. "Long Task" markers should be absent or under 50 ms. - FPS meter: Enable the FPS meter overlay (DevTools > More tools > Rendering > Frame rendering stats). Frame rate must stay at or above 55 fps throughout continuous scroll; a brief 40 fps dip during the actual DOM append is acceptable.
- Observer fires once per batch: In the Console, add
console.log('sentinel hit')inside the callback. During a continuous scroll, you should see one log entry per content batch — not tens of entries per second as a scroll listener would produce. - AbortController guard: Throttle the network to Slow 3G in the Network panel, then scroll past the sentinel twice in quick succession before the first fetch completes. Only one new batch should appear — confirm no duplicate items exist in the DOM.
- Memory stability: Open the Memory panel and take a heap snapshot before and after 20 load-more cycles. Detached DOM nodes should remain at zero; if they grow, the cleanup function is not being called on item removal.
Common Mistakes to Avoid
- Observing the last list item instead of a dedicated sentinel. When you observe a content node, the observer fires as the node enters the viewport — already visible. The user has to wait for the fetch to complete with nothing below. A sentinel placed 200 px below the last item fires while content is still in view, making latency invisible.
- Skipping
observer.disconnect()on unmount in SPAs. Each route visit that recreates the list without tearing down the old observer accumulates live observers. The observer lifecycle and memory management pattern requires explicit disconnect on every teardown path. - Reading
entries[0].boundingClientRectinside the callback. Accessing geometry properties on anIntersectionObserverEntrythat was already computed is safe. Reading live layout properties —element.offsetHeight,element.getBoundingClientRect()— inside the callback forces a new reflow. Use the data the entry already carries (isIntersecting,intersectionRatio) and nothing else. - Using
threshold: [0.5, 1.0]for a sentinel element. A sentinel is a zero-height or 1 px element. Non-zero thresholds may never fire because the element never occupies enough of the root to cross a 50% or 100% threshold. Keepthreshold: 0for sentinels; reserve fractional thresholds for visibility tracking use cases.
Related
- Infinite Scroll & Pagination — parent overview covering pagination state, page-size strategies, and scroll restoration
- Optimizing IntersectionObserver for 1000 List Items — batching and throttling when the observed node count is large
- Preventing Memory Leaks in Long-Running Observers — WeakMap patterns and disconnect discipline for SPA environments
- Syncing Observer Callbacks with requestAnimationFrame — deep dive on double-rAF scheduling and mid-frame mutation safety
↑ Back to Implementation Patterns for Viewport & Resize Tracking