Implementing Infinite Scroll with JS Observers
A complete tutorial on implementing infinite scroll with JavaScript Intersection Observer. Covers sentinel element pattern, loading indicators, page tracking, scroll position restoration, error handling with retry, preventing duplicate fetches, virtualized rendering for performance, back-to-top button, and building a complete infinite scroll controller.
Infinite scroll loads content as the user scrolls, replacing traditional pagination. The Intersection Observer API makes this efficient by detecting when a sentinel element enters the viewport. This guide builds a production-ready implementation with error handling, retry logic, and performance optimization.
For the Intersection Observer API reference, see JS Intersection Observer API: complete tutorial.
Basic Sentinel Pattern
const container = document.getElementById("items");
const sentinel = document.getElementById("scroll-sentinel");
let page = 1;
let isLoading = false;
let hasMore = true;
const observer = new IntersectionObserver(
async (entries) => {
const entry = entries[0];
if (!entry.isIntersecting || isLoading || !hasMore) return;
isLoading = true;
try {
const items = await fetchPage(page);
if (items.length === 0) {
hasMore = false;
sentinel.textContent = "No more items";
observer.disconnect();
return;
}
items.forEach((item) => {
const el = document.createElement("div");
el.className = "item";
el.textContent = item.title;
container.insertBefore(el, sentinel);
});
page++;
} catch (error) {
console.error("Load failed:", error);
} finally {
isLoading = false;
}
},
{
rootMargin: "0px 0px 300px 0px", // Trigger 300px before viewport
}
);
observer.observe(sentinel);
async function fetchPage(pageNum) {
const response = await fetch(`/api/items?page=${pageNum}&limit=20`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}<div id="items">
<!-- Items are inserted before the sentinel -->
<div id="scroll-sentinel" class="loading-indicator">Loading...</div>
</div>Infinite Scroll Controller
class InfiniteScroll {
constructor(options) {
this.container = document.querySelector(options.container);
this.fetchFn = options.fetch;
this.renderFn = options.render;
this.pageSize = options.pageSize || 20;
this.rootMargin = options.rootMargin || "300px";
this.maxRetries = options.maxRetries || 3;
this.page = 1;
this.isLoading = false;
this.hasMore = true;
this.retryCount = 0;
this.totalLoaded = 0;
this.observer = null;
this.sentinel = null;
this.statusEl = null;
this.init();
}
init() {
// Create sentinel element
this.sentinel = document.createElement("div");
this.sentinel.className = "infinite-scroll-sentinel";
this.container.appendChild(this.sentinel);
// Create status element
this.statusEl = document.createElement("div");
this.statusEl.className = "infinite-scroll-status";
this.statusEl.setAttribute("aria-live", "polite");
this.container.appendChild(this.statusEl);
// Create observer
this.observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
this.loadMore();
}
},
{ rootMargin: `0px 0px ${this.rootMargin} 0px` }
);
this.observer.observe(this.sentinel);
}
async loadMore() {
if (this.isLoading || !this.hasMore) return;
this.isLoading = true;
this.showStatus("loading");
try {
const items = await this.fetchFn(this.page, this.pageSize);
if (!items || items.length === 0) {
this.hasMore = false;
this.showStatus("end");
this.observer.disconnect();
return;
}
// Render items before the sentinel
const fragment = document.createDocumentFragment();
items.forEach((item) => {
const el = this.renderFn(item);
fragment.appendChild(el);
});
this.container.insertBefore(fragment, this.sentinel);
this.totalLoaded += items.length;
this.page++;
this.retryCount = 0;
if (items.length < this.pageSize) {
this.hasMore = false;
this.showStatus("end");
this.observer.disconnect();
} else {
this.showStatus("idle");
}
} catch (error) {
console.error(`Load failed (attempt ${this.retryCount + 1}):`, error);
this.retryCount++;
if (this.retryCount >= this.maxRetries) {
this.showStatus("error");
this.observer.disconnect();
} else {
this.showStatus("retry");
// Auto-retry after delay
setTimeout(() => {
this.isLoading = false;
this.loadMore();
}, 1000 * this.retryCount);
return;
}
} finally {
this.isLoading = false;
}
}
showStatus(state) {
const messages = {
loading: "Loading more items...",
end: `All ${this.totalLoaded} items loaded`,
error: "Failed to load. <button class='retry-btn'>Retry</button>",
retry: `Retrying... (attempt ${this.retryCount}/${this.maxRetries})`,
idle: "",
};
this.statusEl.innerHTML = messages[state] || "";
this.statusEl.dataset.state = state;
if (state === "error") {
const retryBtn = this.statusEl.querySelector(".retry-btn");
retryBtn.addEventListener("click", () => {
this.retryCount = 0;
this.hasMore = true;
this.observer.observe(this.sentinel);
this.loadMore();
});
}
}
reset() {
// Clear all items except sentinel and status
const children = [...this.container.children];
children.forEach((child) => {
if (child !== this.sentinel && child !== this.statusEl) {
child.remove();
}
});
this.page = 1;
this.isLoading = false;
this.hasMore = true;
this.retryCount = 0;
this.totalLoaded = 0;
this.observer.observe(this.sentinel);
this.loadMore();
}
destroy() {
this.observer.disconnect();
this.sentinel.remove();
this.statusEl.remove();
}
}Usage
const scroller = new InfiniteScroll({
container: "#article-list",
pageSize: 20,
rootMargin: "400px",
maxRetries: 3,
fetch: async (page, limit) => {
const res = await fetch(`/api/articles?page=${page}&limit=${limit}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
render: (article) => {
const card = document.createElement("article");
card.className = "article-card";
card.innerHTML = `
<h3>${article.title}</h3>
<p>${article.excerpt}</p>
<time>${new Date(article.date).toLocaleDateString()}</time>
`;
return card;
},
});Scroll Position Restoration
class ScrollRestorer {
constructor(scrollerInstance) {
this.scroller = scrollerInstance;
this.storageKey = `scroll:${window.location.pathname}`;
}
save() {
const state = {
scrollY: window.scrollY,
page: this.scroller.page,
totalLoaded: this.scroller.totalLoaded,
};
sessionStorage.setItem(this.storageKey, JSON.stringify(state));
}
async restore() {
const raw = sessionStorage.getItem(this.storageKey);
if (!raw) return false;
try {
const state = JSON.parse(raw);
// Load all pages up to saved position
for (let p = 1; p < state.page; p++) {
await this.scroller.loadMore();
}
// Restore scroll position
requestAnimationFrame(() => {
window.scrollTo(0, state.scrollY);
});
return true;
} catch {
return false;
}
}
init() {
// Save position before leaving
window.addEventListener("beforeunload", () => this.save());
// Save on link clicks
document.addEventListener("click", (event) => {
if (event.target.closest("a[href]")) {
this.save();
}
});
}
}
const restorer = new ScrollRestorer(scroller);
restorer.init();
restorer.restore();See JS sessionStorage API guide for more on session-scoped storage.
Back-to-Top Button
function createBackToTop(options = {}) {
const threshold = options.threshold || 500;
const button = document.createElement("button");
button.className = "back-to-top";
button.setAttribute("aria-label", "Back to top");
button.innerHTML = "↑";
button.style.cssText = `
position: fixed; bottom: 24px; right: 24px;
width: 44px; height: 44px; border-radius: 50%;
border: none; background: #3b82f6; color: white;
font-size: 20px; cursor: pointer; opacity: 0;
transition: opacity 0.3s; z-index: 1000;
`;
document.body.appendChild(button);
const topSentinel = document.createElement("div");
topSentinel.style.cssText = `position: absolute; top: ${threshold}px; height: 1px;`;
document.body.appendChild(topSentinel);
const observer = new IntersectionObserver((entries) => {
button.style.opacity = entries[0].isIntersecting ? "0" : "1";
button.style.pointerEvents = entries[0].isIntersecting ? "none" : "auto";
});
observer.observe(topSentinel);
button.addEventListener("click", () => {
window.scrollTo({ top: 0, behavior: "smooth" });
});
}
createBackToTop({ threshold: 600 });Performance Considerations
| Technique | Benefit | When to Use |
|---|---|---|
rootMargin: "300px" | Pre-fetches before user reaches bottom | Always |
DocumentFragment | Batch DOM insertions | Many items per page |
| Debounce fetches | Prevent rapid duplicate requests | Fast scrolling |
| Skeleton placeholders | Perceived performance | Loading states |
requestIdleCallback | Non-urgent rendering | Low-priority items |
| Virtual scrolling | Handle 10K+ items | Very large lists |
// Batch rendering with requestAnimationFrame
function batchRender(items, renderFn, container, batchSize = 50) {
let index = 0;
function renderBatch() {
const fragment = document.createDocumentFragment();
const end = Math.min(index + batchSize, items.length);
for (let i = index; i < end; i++) {
fragment.appendChild(renderFn(items[i]));
}
container.appendChild(fragment);
index = end;
if (index < items.length) {
requestAnimationFrame(renderBatch);
}
}
requestAnimationFrame(renderBatch);
}Accessibility
// Announce new content to screen readers
function announceNewItems(count) {
const announcer = document.getElementById("scroll-announcer") ||
(() => {
const el = document.createElement("div");
el.id = "scroll-announcer";
el.setAttribute("aria-live", "polite");
el.setAttribute("aria-atomic", "true");
el.className = "sr-only";
document.body.appendChild(el);
return el;
})();
announcer.textContent = "";
requestAnimationFrame(() => {
announcer.textContent = `${count} new items loaded`;
});
}
// Add keyboard shortcut to load more
document.addEventListener("keydown", (event) => {
if (event.key === "End" && !scroller.isLoading && scroller.hasMore) {
scroller.loadMore();
}
});Rune AI
Key Insights
- Sentinel element pattern: Place an invisible element at the bottom of the list; when it enters the viewport, trigger the next fetch
- rootMargin for pre-fetching: Set
rootMargin: "300px"to start loading before the user reaches the bottom, creating a seamless experience - Loading and hasMore flags: Prevent duplicate fetches with an
isLoadingflag and stop observing whenhasMoreis false - Error handling with retry: Automatically retry failed fetches with exponential backoff; show a manual retry button after max attempts
- DocumentFragment for batch DOM insertion: Append all new items to a fragment first, then insert once to minimize reflows and repaints
Frequently Asked Questions
Why use Intersection Observer instead of scroll events for infinite scroll?
How do I prevent duplicate fetches during fast scrolling?
Should I use infinite scroll or traditional pagination?
How do I handle the case where all items fit on one screen?
Can I combine infinite scroll with URL-based pagination?
Conclusion
Infinite scroll with Intersection Observer replaces expensive scroll listeners with a performant sentinel-based approach. Use the controller class for error handling, retry logic, and status feedback. Add scroll position restoration with sessionStorage, back-to-top buttons, and screen reader announcements for accessibility. For the Observer API reference, see JS Intersection Observer API: complete tutorial. For DOM change monitoring, see JavaScript Mutation Observer: complete tutorial.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.