Optimize Infinite Scroll Performance in JavaScript
Advertisement
🧩 Scenario
Architecture Walkthrough
Problem 1: Scroll Event Overhead
A naive infinite scroll implementation attaches a scroll event listener to window or the container. This event fires continuously while the user scrolls, often 60 or more times per second. Each handler call checks whether the user has scrolled near the bottom using scrollTop + clientHeight >= scrollHeight. This is not free: reading these properties can force a synchronous layout if any pending style or DOM changes have not yet been flushed.
IntersectionObserver is the correct replacement. Instead of running JavaScript on every scroll pixel, you place a sentinel element (an empty div) at the end of the list and observe it. The observer fires only when the sentinel enters or leaves the viewport, meaning the callback runs at most once per page load. The browser handles the visibility calculation natively, off the main thread.
Problem 2: Unbounded DOM Growth
Even with IntersectionObserver, after loading 500 items the DOM has 500 nodes. Every layout calculation (triggered by scrolling, resizing, or reading layout properties) must consider all 500 nodes. Every paint covers the visible portion. Browsers optimize for this, but the overhead is measurable past a few hundred complex nodes.
DOM virtualization solves this by rendering only the visible items plus a small buffer. Items scrolled above the viewport are removed from the DOM. Their height is maintained using a spacer element so the scrollbar and scroll position remain accurate. When the user scrolls back up, items are re-rendered. Libraries like react-virtual, @tanstack/virtual, and react-window implement this pattern.
Batching DOM Insertions
Inserting items one at a time in a for loop causes a separate style recalculation for each insert. Using a DocumentFragment batches all insertions into a single DOM operation. The fragment exists in memory and is appended to the live DOM once, triggering exactly one layout recalculation.
Key Code Explained
const list = document.querySelector('.item-list');
const sentinel = document.querySelector('#load-trigger'); // empty div at the bottom
let page = 1;
let isLoading = false;
let hasMore = true;
const observer = new IntersectionObserver(
async (entries) => {
if (!entries[0].isIntersecting || isLoading || !hasMore) return;
isLoading = true;
const items = await fetchPage(page++);
if (items.length === 0) {
hasMore = false;
observer.disconnect(); // no more data — stop observing
return;
}
// Batch all DOM insertions into a single operation
const fragment = document.createDocumentFragment();
items.forEach((item) => {
const li = document.createElement('li');
li.className = 'item';
li.textContent = item.name;
fragment.appendChild(li);
});
list.appendChild(fragment); // single layout recalculation
isLoading = false;
},
{
rootMargin: '300px', // trigger 300px before sentinel enters viewport
threshold: 0,
},
);
observer.observe(sentinel);
// HTML structure:
// <ul class="item-list"></ul>
// <div id="load-trigger"></div> ← sentinel stays at the bottom
// For very long lists: virtualization concept
function VirtualList({ items, itemHeight, containerHeight }) {
const [scrollTop, setScrollTop] = useState(0);
const startIndex = Math.floor(scrollTop / itemHeight);
const visibleCount = Math.ceil(containerHeight / itemHeight);
const endIndex = Math.min(startIndex + visibleCount + 2, items.length); // +2 buffer
const visibleItems = items.slice(startIndex, endIndex);
const totalHeight = items.length * itemHeight;
const offsetY = startIndex * itemHeight;
return (
<div
style={{ height: containerHeight, overflowY: 'scroll', position: 'relative' }}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
>
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ position: 'absolute', top: offsetY, width: '100%' }}>
{visibleItems.map((item) => (
<div key={item.id} style={{ height: itemHeight }}>
{item.name}
</div>
))}
</div>
</div>
</div>
);
}
The rootMargin: '300px' configuration means the observer fires when the sentinel is within 300 pixels of the viewport edge, not when it actually enters the viewport. This gives the data fetch a head start so new items appear before the user reaches the actual bottom of the list.
Tradeoffs
| Approach | Scroll overhead | DOM size concern | Implementation complexity |
|---|---|---|---|
| scroll listener (unthrottled) | Very high | Grows unboundedly | Low |
| IntersectionObserver | Near zero | Grows unboundedly | Low |
| IntersectionObserver + virtual | Near zero | Stays constant | High |
What Interviewers Actually Check
- Whether you replace scroll listeners with IntersectionObserver and can explain why it is more efficient
- Whether you know about the DOM growth problem and that IntersectionObserver alone does not fix it
- Whether you know DocumentFragment for batching DOM insertions
- Whether you know DOM virtualization as the solution to unbounded list sizes
- Whether you disconnect the observer when all data is loaded
Follow-Up Questions
- How does
@tanstack/virtualimplement virtualization and how does it handle items of variable height? - If the API page size is 50 items, how do you choose the IntersectionObserver
rootMarginto ensure new data is ready before the user reaches the bottom? - How would you add a loading skeleton during the fetch and an error state if the request fails?
- What happens to scroll position if you remove items from the top of the list during virtualization, and how do scroll anchoring APIs help?
- How would you implement bi-directional infinite scroll that loads both newer and older items as the user scrolls up or down?
Common Candidate Mistakes
- Not knowing IntersectionObserver exists and proposing a throttled scroll listener as the solution
- Knowing IntersectionObserver but not recognizing that DOM growth is a separate problem that needs virtualization
- Inserting list items one at a time in a loop rather than batching with DocumentFragment
- Not disconnecting the observer after the last page is loaded
- Not handling the loading state flag (
isLoading), which allows the observer to fire multiple times during a single fetch and trigger duplicate requests
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you explain why IntersectionObserver is more efficient than a scroll event for triggering loads?
- Can you implement a sentinel-based load trigger with
rootMarginfor pre-loading? - Can you batch DOM insertions using DocumentFragment to avoid multiple reflows?
- Can you explain what DOM virtualization is and why it is the fix for lists longer than a few hundred items?
- Can you disconnect an IntersectionObserver when no more data is available?
Summary
Infinite scroll performance degrades from two separate causes. The first is scroll event handler overhead: a handler that fires on every scroll pixel and reads layout properties causes synchronous layout recalculations. IntersectionObserver replaces this with a native viewport-intersection check that fires only when a sentinel element enters the viewport, reducing JavaScript execution to one call per page load.
The second cause is unbounded DOM growth. After loading many pages, the list contains hundreds or thousands of nodes and every layout operation must consider all of them. IntersectionObserver does not help here. DOM virtualization is the fix: only the visible items plus a small buffer are kept in the DOM at any time, maintaining constant rendering cost regardless of total list length.
For typical implementations, use an IntersectionObserver on a sentinel element at the bottom of the list with a rootMargin to trigger early, batch insertions with DocumentFragment, and disconnect the observer when the last page is loaded. For lists that grow past several hundred items, integrate a virtualization library to keep DOM size constant.
Why does infinite scroll slow down over time?
Because the DOM grows unboundedly. After hundreds of items, every layout calculation, scroll event, and paint covers thousands of nodes.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement