Step-by-step
-
1
Add a sentinel element below the list
The sentinel is a zero-height
<div>placed immediately after your list container. It has no visual presence — it is purely a position marker the observer watches. When it scrolls into view, you load the next page.html<ul id="post-list"> <!-- Items appended here dynamically --> </ul> <!-- The sentinel: observed by IntersectionObserver --> <div id="scroll-sentinel" aria-hidden="true"></div> <button id="load-more-btn" hidden>Load more</button> -
2
Set up the IntersectionObserver
Create the observer with a callback and options. The
rootMarginof"200px"tells the browser to trigger 200px before the sentinel reaches the viewport edge — giving you a head start on loading so users don't see a blank gap.javascriptconst sentinel = document.getElementById('scroll-sentinel'); const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { loadNextPage(); } }, { root: null, // Observe relative to the browser viewport rootMargin: '200px', // Trigger 200px before the sentinel is visible threshold: 0, // Fire as soon as any pixel enters the margin } ); observer.observe(sentinel); -
3
Track page state with module-level variables
Keep the current page, a
loadingflag, and ahasMoreflag outside the function.loadingprevents overlapping requests when the observer fires multiple times (it can).hasMorestops fetching when the API signals no more pages.javascriptlet currentPage = 1; let loading = false; let hasMore = true; async function loadNextPage() { if (loading || !hasMore) return; // Guard against overlap and end-of-data loading = true; try { const items = await fetchPage(currentPage); if (items.length === 0) { hasMore = false; observer.unobserve(sentinel); // Stop watching — nothing left to load showEndMessage(); return; } appendItems(items); currentPage++; } catch (err) { showError(err.message); } finally { loading = false; } } -
4
Fetch a page of data
A simple wrapper around
fetchthat hits your paginated endpoint and returns the items array. Adjust the URL shape for your API — page number, cursor, or offset are all fine.javascriptasync function fetchPage(page) { const response = await fetch(`/api/posts?page=${page}&per_page=20`); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); // API returns { items: [...], total_pages: N } if (page >= data.total_pages) hasMore = false; return data.items; } -
5
Append items to the DOM safely
Build each item using
createElementandtextContent— never concatenate strings intoinnerHTMLwith API data. UsingDocumentFragmentbatches all the DOM insertions into a single reflow instead of one per item.javascriptfunction appendItems(items) { const list = document.getElementById('post-list'); const fragment = document.createDocumentFragment(); items.forEach((post) => { const li = document.createElement('li'); li.className = 'post-item'; const title = document.createElement('h3'); title.textContent = post.title; // Safe — escapes HTML const excerpt = document.createElement('p'); excerpt.textContent = post.excerpt; li.append(title, excerpt); fragment.appendChild(li); }); list.appendChild(fragment); // One DOM mutation instead of N } -
6
Unobserve when there is no more data
Once the API returns an empty page or a flag indicating the last page, stop the observer and show the user a clear signal that they've reached the end. Optionally show a "Load more" button as a fallback for power users or accessibility scenarios where auto-loading isn't ideal.
javascriptfunction showEndMessage() { sentinel.hidden = true; const loadMoreBtn = document.getElementById('load-more-btn'); loadMoreBtn.hidden = false; loadMoreBtn.textContent = 'You\'ve reached the end'; loadMoreBtn.disabled = true; // Or: replace with a friendlier end-of-feed message const msg = document.createElement('p'); msg.className = 'end-of-feed'; msg.textContent = 'No more posts to load.'; document.getElementById('post-list').after(msg); } -
7
Provide a "Load more" button fallback
Some users disable JavaScript features, some environments block
IntersectionObserver(rare but possible in older WebViews), and some users simply prefer explicit pagination. Wire a manual button as a fallback — call the sameloadNextPagefunction.javascriptconst loadMoreBtn = document.getElementById('load-more-btn'); loadMoreBtn.addEventListener('click', () => { loadNextPage(); }); // During loading: update button text so users know something is happening async function loadNextPage() { if (loading || !hasMore) return; loading = true; loadMoreBtn.textContent = 'Loading…'; loadMoreBtn.disabled = true; try { // ... fetch and append logic } finally { loading = false; if (hasMore) { loadMoreBtn.textContent = 'Load more'; loadMoreBtn.disabled = false; } } } -
8
Load the first page on initialisation
Call
loadNextPage()once when the page loads so the list is not empty on first render. The observer will handle every subsequent load automatically as the user scrolls.javascript// Entry point: load page 1 immediately, then let the observer take over loadNextPage();
Tips & gotchas
- Set <code>rootMargin</code> to at least <code>"200px"</code> so loading starts before the sentinel is fully visible — prevents empty gaps on fast scrollers.
- For cursor-based APIs, store the <code>next_cursor</code> from the last response instead of incrementing a page number.
- If items can be deleted while the user scrolls, use unique server-generated IDs and de-duplicate before appending: track rendered IDs in a <code>Set</code>.
- For virtual scrolling (thousands of items), <code>IntersectionObserver</code> alone is not enough — look at libraries like <strong>TanStack Virtual</strong> that recycle DOM nodes.
- Always test with throttled network in DevTools to ensure the loading state is visible and the guard against double-fetching works.
Wrapping up
IntersectionObserver turns infinite scroll from a scroll-event performance nightmare into a declarative, efficient pattern. The sentinel approach is clean, the observer fires only when needed, and the loading flag guard makes the whole thing race-condition-free. Add a "Load more" fallback and you have covered every user scenario.