Implement Infinite Scroll with IntersectionObserver
Load the next page when a sentinel enters the viewport, guard against duplicate requests, and keep a keyboard-accessible fallback for users who never scroll.
Task
Add cursor-based infinite scrolling that does not double-fetch and remains usable without a mouse.
When to use this
A long list renders every row at once and janks; naive infinite scroll then fires overlapping requests as the user scrolls fast.
Prerequisites
- A paginated API with stable cursors
- A list container with a sentinel element
Steps
1. Observe a sentinel below the list and trigger a fetch when it becomes visible.
Expected output: A new page loads only near the end of the list.
2. Track an in-flight flag and the last cursor so concurrent triggers fetch once.
curl "https://api.example.com/items?cursor=eyJpZCI6MTAwfQ"Expected output: Rapid scrolling does not create duplicate requests.
3. Append results and stop observing when the API reports no next cursor.
Expected output: The observer disconnects at the end of the data.
4. Provide a visible Load more button as a no-JS and accessibility fallback.
Expected output: Keyboard-only users can still load pages.
Troubleshooting
Problem
Duplicate rows
Solution
Deduplicate by item id when appending; a retried request can return items already shown.
Problem
Sentinel never intersects
Solution
Ensure the sentinel has height and is not inside a display:none subtree.
Limitations
- Infinite scroll hurts deep linking and restoring a scroll position, so keep page numbers where users need them.
- It is unsuitable for long documents where users rely on find-in-page.
Context
Add cursor-based infinite scrolling that does not double-fetch and remains usable without a mouse. A long list renders every row at once and janks; naive infinite scroll then fires overlapping requests as the user scrolls fast.
Steps
- Observe a sentinel below the list and trigger a fetch when it becomes visible. Expected: A new page loads only near the end of the list.
- Track an in-flight flag and the last cursor so concurrent triggers fetch once. Expected: Rapid scrolling does not create duplicate requests.
- Append results and stop observing when the API reports no next cursor. Expected: The observer disconnects at the end of the data.
- Provide a visible Load more button as a no-JS and accessibility fallback. Expected: Keyboard-only users can still load pages.
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting && !loading && cursor) loadMore(cursor);
});
io.observe(sentinel);
// always render a fallback <button>Load more</button>
Verify
- A new page loads only near the end of the list.
- Rapid scrolling does not create duplicate requests.
- The observer disconnects at the end of the data.
- Keyboard-only users can still load pages.
Limits
- Infinite scroll hurts deep linking and restoring a scroll position, so keep page numbers where users need them.
- It is unsuitable for long documents where users rely on find-in-page.