Pagination is a technique used to divide large datasets or content into smaller, manageable chunks or "pages." Instead of loading all data at once, pagination allows users to navigate through content in digestible portions.
Reduces initial load time by fetching only necessary data
Prevents browser from being overwhelmed with too much DOM content
Makes content easier to digest and navigate
Reduces data transfer and server load
| Use Case | Best Pagination Type | Example |
|---|---|---|
| Search Results | Numbered Pagination | Google Search, Amazon Products |
| Social Media Feeds | Infinite Scroll | Facebook, Instagram, Twitter |
| Article Lists | Load More Button | Medium, Blog Posts |
| API Responses | Cursor Pagination | REST APIs, Database queries |
Traditional page numbers with Previous/Next buttons
Button to fetch and append additional content
Automatically loads content as user scrolls down
Uses cursors/tokens for efficient database queries
| Type | Performance | UX | Implementation | SEO |
|---|---|---|---|---|
| Numbered | Good | Excellent | Easy | Excellent |
| Load More | Very Good | Good | Easy | Poor |
| Infinite Scroll | Excellent | Very Good | Medium | Poor |
| Cursor-Based | Excellent | Good | Complex | Medium |
Traditional pagination with numbered pages, Previous/Next buttons, and direct page access. Most common in search results, product catalogs, and data tables.
Google, Amazon, eBay - users need to jump to specific pages
Admin panels, reports - precise navigation required
E-commerce listings with filtering and sorting
Blog posts, news articles - SEO-friendly structure
function generatePageNumbers(current, total) {
const pages = [];
const delta = 2; // Pages around current
// Always show first page
pages.push(1);
if (current > delta + 2) pages.push('...');
// Show pages around current
const start = Math.max(2, current - delta);
const end = Math.min(total - 1, current + delta);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (current < total - delta - 1) pages.push('...');
// Always show last page
if (total > 1) pages.push(total);
return pages;
}
A button that fetches and appends additional content to the current page. Provides user control over loading while maintaining a single-page experience.
Medium, Dev.to - progressive content discovery
Pinterest-style layouts with user control
Native app feel with manual loading
Music playlists, video lists with preview
class LoadMorePagination {
constructor(container, loadMoreBtn) {
this.container = container;
this.button = loadMoreBtn;
this.currentPage = 1;
this.isLoading = false;
this.hasMore = true;
}
async loadMore() {
if (this.isLoading || !this.hasMore) return;
try {
this.isLoading = true;
this.updateButtonState('loading');
const response = await this.fetchData(this.currentPage);
this.renderItems(response.data);
this.currentPage++;
this.hasMore = response.hasMore;
if (!this.hasMore) {
this.button.style.display = 'none';
}
} catch (error) {
this.handleError(error);
} finally {
this.isLoading = false;
this.updateButtonState('default');
}
}
}
Automatically loads more content as the user scrolls down. Creates a seamless, endless browsing experience popular in social media and content discovery platforms.
Facebook, Instagram, Twitter - endless content consumption
Pinterest, Etsy - visual browsing and exploration
Netflix, YouTube - continuous content recommendations
Real-time updates and breaking news streams
A modern browser API that efficiently detects when elements enter or leave the viewport. It replaces the need for expensive scroll event listeners and provides better performance.
| Aspect | Scroll Events (Old Way) | Intersection Observer (Modern) |
|---|---|---|
| Performance | โ Fires constantly, expensive calculations | โ Efficient, runs asynchronously |
| Battery Usage | โ High CPU usage on mobile | โ Optimized for mobile devices |
| Accuracy | ๐ก Manual calculations, prone to errors | โ Browser-native, precise detection |
| Cross-frame | โ Complex iframe handling | โ Works across iframe boundaries |
Intersection Observer works by watching target elements and firing callbacks when they intersect with a root element (usually the viewport).
// Create observer instance with callback and options
const observer = new IntersectionObserver(callback, options);
// The observer is now ready but not watching anything yet
console.log('Observer created:', observer);
function callback(entries, observer) {
// entries: Array of IntersectionObserverEntry objects
// observer: The IntersectionObserver instance that triggered this callback
entries.forEach(entry => {
console.log('Entry details:', {
target: entry.target, // The DOM element being observed
isIntersecting: entry.isIntersecting, // Boolean: is element visible?
intersectionRatio: entry.intersectionRatio, // 0.0 to 1.0 visibility ratio
intersectionRect: entry.intersectionRect, // Visible portion rectangle
boundingClientRect: entry.boundingClientRect, // Full element rectangle
rootBounds: entry.rootBounds, // Root element bounds
time: entry.time // Timestamp when intersection occurred
});
if (entry.isIntersecting) {
// Element became visible
console.log('Element entered viewport:', entry.target);
loadMoreContent();
// Optional: Stop observing this element
// observer.unobserve(entry.target);
} else {
// Element left viewport
console.log('Element left viewport:', entry.target);
}
});
}
const options = {
// root: The element used as viewport for checking visibility
root: null, // null = browser viewport
// root: document.querySelector('#scrollContainer'), // Custom container
// rootMargin: Margin around root (like CSS margin)
rootMargin: '100px', // Trigger 100px before entering viewport
// rootMargin: '10px 20px 30px 40px', // top right bottom left
// rootMargin: '-50px', // Trigger 50px AFTER entering viewport
// threshold: Visibility percentage that triggers callback
threshold: 0.1, // Trigger when 10% visible
// threshold: [0, 0.25, 0.5, 0.75, 1], // Multiple thresholds
// threshold: 1.0, // Trigger only when 100% visible
};
console.log('Observer options:', options);
// Create sentinel element (invisible trigger)
const sentinel = document.createElement('div');
sentinel.className = 'infinite-scroll-sentinel';
sentinel.style.height = '1px';
sentinel.style.opacity = '0';
sentinel.setAttribute('aria-hidden', 'true');
// Add sentinel to DOM
document.querySelector('#content-container').appendChild(sentinel);
// Start observing the sentinel
observer.observe(sentinel);
console.log('Now observing:', sentinel);
// You can observe multiple elements
const images = document.querySelectorAll('img[data-lazy]');
images.forEach(img => observer.observe(img));
class InfiniteScrollWithIntersectionObserver {
constructor(container) {
this.container = container;
this.currentPage = 1;
this.isLoading = false;
this.hasMore = true;
this.setupObserver();
this.createSentinel();
}
setupObserver() {
// Configure observer options
const options = {
root: null, // Use viewport
rootMargin: '200px', // Load 200px before visible
threshold: 0.1 // Trigger at 10% visibility
};
// Create observer with callback
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
console.log('Intersection detected:', {
isIntersecting: entry.isIntersecting,
ratio: entry.intersectionRatio,
target: entry.target.className
});
// Check if sentinel is visible and we can load more
if (entry.isIntersecting &&
!this.isLoading &&
this.hasMore &&
entry.target.classList.contains('sentinel')) {
console.log('๐ Loading more content...');
this.loadMore();
}
});
}, options);
console.log('โ
IntersectionObserver created');
}
async loadMore() {
try {
this.isLoading = true;
console.log(`๐ฆ Fetching page ${this.currentPage}...`);
// Simulate API call
const response = await this.fetchData(this.currentPage);
// Add new content
this.renderItems(response.data);
// Update state
this.currentPage++;
this.hasMore = response.hasMore;
// Move sentinel to end of new content
this.container.appendChild(this.sentinel);
} catch (error) {
console.error('โ Error loading:', error);
} finally {
this.isLoading = false;
}
}
destroy() {
// Cleanup: disconnect observer
this.observer.disconnect();
console.log('๐งน Observer disconnected');
}
}
null: Use viewport
Element: Use specific container
Use case: Scrollable containers
'100px': Trigger early
'0px': Trigger exactly at edge
Use case: Preloading content
0.0: Any pixel visible
0.5: 50% visible
Use case: Fine-tune trigger point
Purpose: Invisible trigger
Position: End of content
Use case: Loading indicator
class InfiniteScroll {
constructor(container, options = {}) {
this.container = container;
this.options = {
root: null, // Use viewport
rootMargin: '200px', // Load 200px before visible
threshold: 0.1, // Trigger at 10% visibility
...options
};
this.currentPage = 1;
this.isLoading = false;
this.hasMore = true;
this.createSentinel();
this.setupObserver();
}
createSentinel() {
// Create invisible trigger element
this.sentinel = document.createElement('div');
this.sentinel.className = 'infinite-scroll-sentinel';
this.sentinel.style.height = '1px';
this.sentinel.style.opacity = '0';
this.sentinel.setAttribute('aria-hidden', 'true');
// Add to end of container
this.container.appendChild(this.sentinel);
}
setupObserver() {
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// Check if sentinel is intersecting (visible)
if (entry.isIntersecting &&
!this.isLoading &&
this.hasMore) {
console.log('๐ Sentinel visible - loading more content');
this.loadMore();
}
});
}, this.options);
// Start observing the sentinel
this.observer.observe(this.sentinel);
}
async loadMore() {
try {
this.isLoading = true;
this.showLoading();
// Fetch more data
const response = await this.fetchData(this.currentPage);
// Render new items
this.renderItems(response.data);
// Update state
this.currentPage++;
this.hasMore = response.hasMore;
// Move sentinel to new end position
this.moveSentinel();
} catch (error) {
this.handleError(error);
} finally {
this.isLoading = false;
this.hideLoading();
}
}
moveSentinel() {
// Keep sentinel at the end of content
this.container.appendChild(this.sentinel);
}
destroy() {
// Cleanup when component unmounts
this.observer.disconnect();
if (this.sentinel.parentNode) {
this.sentinel.parentNode.removeChild(this.sentinel);
}
}
}
Search engine crawlers cannot interact with JavaScript the same way users do, making infinite scroll content largely invisible to search engines.
Crawlers start by reading the initial HTML response from the server
While modern crawlers can execute some JS, they don't scroll or interact like users
Crawlers don't scroll down, click buttons, or trigger scroll events
Crawlers have limited time budget per page - they won't wait for slow AJAX calls
| Content Location | User Experience | Crawler Experience | SEO Impact |
|---|---|---|---|
| Initial Load | โ Sees first 10-20 items | โ Can index these items | โ Indexed |
| After 1st Scroll | โ Sees next 10-20 items | โ Never scrolls down | โ Not indexed |
| Deep Content | โ Accessible via scrolling | โ Completely invisible | โ Lost SEO value |
| Dynamic Content | โ Loads via AJAX calls | โ No scroll = no AJAX triggers | โ Zero discoverability |
// 1. Content loads only on scroll events
window.addEventListener('scroll', () => {
if (nearBottom()) {
loadMoreContent(); // Crawler never triggers this
}
});
// 2. Intersection Observer requires user interaction
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadMore(); // Crawler doesn't scroll to trigger this
}
});
});
// 3. AJAX calls happen asynchronously
async function loadMoreItems() {
const response = await fetch('/api/items?page=2');
// Crawler may not wait for this async operation
appendItems(response.data);
}
// 4. No direct URLs for deep content
// Page 2, 3, 4+ content has no unique URL
// Crawler can't discover these "pages"
Scenario: Online store with 1000 products using infinite scroll
Scenario: News site with infinite scroll for articles
Provide traditional pagination links as fallback for crawlers
Include all content URLs in sitemap for crawler discovery
Pre-render more content on server for initial page load
Start with static pagination, enhance with infinite scroll
Consider virtualization to maintain constant DOM size and smooth performance. See our dedicated Virtualization slide for complete implementation details and examples.
| Scenario | Without Virtualization | With Virtualization | Performance Impact |
|---|---|---|---|
| 1,000 items loaded | 1,000 DOM nodes | ~10 DOM nodes | 99% reduction |
| 10,000 items loaded | 10,000 DOM nodes | ~10 DOM nodes | 99.9% reduction |
| Memory usage | Grows linearly | Constant | Stable performance |
| Scroll performance | Degrades over time | Always smooth | Consistent 60fps |
Calculate which items are visible based on scroll position and container height
Render only visible items plus small buffer above/below viewport
Update visible items as user scrolls, maintaining smooth experience
Maintain total scrollable height for proper scrollbar behavior
// Simplified virtualization example - production ready
class VirtualizedInfiniteScroll {
constructor(container, options = {}) {
this.container = container;
this.itemHeight = options.itemHeight || 100;
this.bufferSize = options.bufferSize || 5;
this.overscan = options.overscan || 3;
this.items = []; // All data items
this.visibleItems = []; // Currently rendered items
this.startIndex = 0; // First visible item index
this.endIndex = 0; // Last visible item index
this.setupContainer();
this.setupScrollListener();
this.setupIntersectionObserver();
}
setupContainer() {
// Create virtual container structure
this.scrollContainer = document.createElement('div');
this.scrollContainer.className = 'virtual-scroll-container';
this.scrollContainer.style.height = '400px';
this.scrollContainer.style.overflow = 'auto';
this.contentContainer = document.createElement('div');
this.contentContainer.className = 'virtual-content';
this.contentContainer.style.position = 'relative';
this.viewport = document.createElement('div');
this.viewport.className = 'virtual-viewport';
this.viewport.style.position = 'absolute';
this.viewport.style.top = '0';
this.viewport.style.width = '100%';
this.contentContainer.appendChild(this.viewport);
this.scrollContainer.appendChild(this.contentContainer);
this.container.appendChild(this.scrollContainer);
// Add sentinel for infinite scroll
this.sentinel = document.createElement('div');
this.sentinel.className = 'virtual-sentinel';
this.sentinel.style.height = '1px';
this.viewport.appendChild(this.sentinel);
}
updateVirtualization() {
const scrollTop = this.scrollContainer.scrollTop;
const containerHeight = this.scrollContainer.clientHeight;
// Calculate visible range with overscan
this.startIndex = Math.max(0,
Math.floor(scrollTop / this.itemHeight) - this.overscan
);
this.endIndex = Math.min(this.items.length - 1,
Math.ceil((scrollTop + containerHeight) / this.itemHeight) + this.overscan
);
// Update total content height
const totalHeight = this.items.length * this.itemHeight;
this.contentContainer.style.height = totalHeight + 'px';
// Render visible items
this.renderVisibleItems();
// Position sentinel for infinite loading
this.positionSentinel();
}
renderVisibleItems() {
// Clear current items (except sentinel)
const children = Array.from(this.viewport.children);
children.forEach(child => {
if (!child.classList.contains('virtual-sentinel')) {
child.remove();
}
});
// Render visible items with absolute positioning
for (let i = this.startIndex; i <= this.endIndex; i++) {
if (this.items[i]) {
const itemEl = this.createItemElement(this.items[i], i);
itemEl.style.position = 'absolute';
itemEl.style.top = (i * this.itemHeight) + 'px';
itemEl.style.height = this.itemHeight + 'px';
itemEl.style.width = '100%';
this.viewport.appendChild(itemEl);
}
}
console.log(`๐ญ Rendered ${this.endIndex - this.startIndex + 1} items of ${this.items.length} total`);
}
positionSentinel() {
// Position sentinel near end for infinite loading
const sentinelPosition = Math.max(0,
(this.items.length - this.bufferSize) * this.itemHeight
);
this.sentinel.style.position = 'absolute';
this.sentinel.style.top = sentinelPosition + 'px';
}
addItems(newItems) {
const startIndex = this.items.length;
this.items.push(...newItems);
// Update virtualization after adding items
this.updateVirtualization();
console.log(`๐ฆ Added ${newItems.length} items. Total: ${this.items.length}`);
}
setupScrollListener() {
// Throttled scroll handler for virtualization
let scrollTimeout;
this.scrollContainer.addEventListener('scroll', () => {
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
scrollTimeout = setTimeout(() => {
this.updateVirtualization();
}, 16); // ~60fps
});
}
createItemElement(item, index) {
const itemEl = document.createElement('div');
itemEl.className = 'virtual-item';
itemEl.innerHTML = `
Item ${index + 1}: ${item.title}
${item.description}
Virtual Index: ${index}
`;
return itemEl;
}
}
@tanstack/react-virtual
Modern, lightweight virtualization for React with dynamic heights support
react-window
Popular library by Brian Vaughn, optimized for performance
Virtual Scroller
Framework-agnostic solutions for any JavaScript application
Ion Virtual Scroll
Ionic framework's virtualization for mobile apps
| Use Case | Regular Infinite Scroll | Virtualized Infinite Scroll | Recommendation |
|---|---|---|---|
| < 100 items | โ Simple, fast | โ Overkill | Use regular |
| 100-1,000 items | ๐ก May slow down | โ Consistent performance | Consider virtualization |
| > 1,000 items | โ Performance issues | โ Essential | Must use virtualization |
| Mobile devices | โ Memory constraints | โ Battery efficient | Strongly recommended |
// Check for Intersection Observer support
if ('IntersectionObserver' in window) {
// Use modern Intersection Observer
new InfiniteScroll(container);
} else {
// Fallback to scroll events for older browsers
new LegacyInfiniteScroll(container);
}
// Polyfill for older browsers
// npm install intersection-observer
import 'intersection-observer';
Uses unique identifiers (cursors) instead of page numbers. Ideal for real-time data, large datasets, and APIs where data changes frequently.
Chat messages, live feeds - data constantly changing
Millions of records - OFFSET becomes too slow
GraphQL, REST APIs - efficient data fetching
Transactions, logs - consistent ordering critical
-- First page
SELECT * FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 10;
-- Next page (cursor = last item's created_at + id)
SELECT * FROM posts
WHERE (created_at, id) < ('2024-01-15 10:30:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 10;
-- Previous page (cursor = first item's created_at + id)
SELECT * FROM posts
WHERE (created_at, id) > ('2024-01-15 11:00:00', 12350)
ORDER BY created_at ASC, id ASC
LIMIT 10;
{
"data": [...],
"pagination": {
"hasNext": true,
"hasPrev": false,
"nextCursor": "eyJjcmVhdGVkX2F0IjoiMjAyNC0wMS0xNSJ9",
"prevCursor": null,
"totalCount": null // Often omitted for performance
}
}
Virtualization (also called "windowing") renders only the visible items in the viewport, dramatically reducing DOM nodes and memory usage. Perfect for infinite scroll with thousands of items.
| Scenario | Without Virtualization | With Virtualization | Performance Impact |
|---|---|---|---|
| 1,000 items loaded | 1,000 DOM nodes | ~10 DOM nodes | 99% reduction |
| 10,000 items loaded | 10,000 DOM nodes | ~10 DOM nodes | 99.9% reduction |
| Memory usage | Grows linearly | Constant | Stable performance |
| Scroll performance | Degrades over time | Always smooth | Consistent 60fps |
Calculate which items are visible based on scroll position and container height
Render only visible items plus small buffer above/below viewport
Update visible items as user scrolls, maintaining smooth experience
Maintain total scrollable height for proper scrollbar behavior
class VirtualizedInfiniteScroll {
constructor(container, options = {}) {
this.container = container;
this.itemHeight = options.itemHeight || 100;
this.bufferSize = options.bufferSize || 5;
this.overscan = options.overscan || 3;
this.items = []; // All data items
this.visibleItems = []; // Currently rendered items
this.startIndex = 0; // First visible item index
this.endIndex = 0; // Last visible item index
this.setupContainer();
this.setupScrollListener();
}
updateVirtualization() {
const scrollTop = this.scrollContainer.scrollTop;
const containerHeight = this.scrollContainer.clientHeight;
// Calculate visible range with overscan
this.startIndex = Math.max(0,
Math.floor(scrollTop / this.itemHeight) - this.overscan
);
this.endIndex = Math.min(this.items.length - 1,
Math.ceil((scrollTop + containerHeight) / this.itemHeight) + this.overscan
);
// Update total content height
const totalHeight = this.items.length * this.itemHeight;
this.contentContainer.style.height = totalHeight + 'px';
// Render visible items
this.renderVisibleItems();
// Position sentinel for infinite loading
this.positionSentinel();
}
renderVisibleItems() {
// Clear current items (except sentinel)
const children = Array.from(this.viewport.children);
children.forEach(child => {
if (!child.classList.contains('virtual-sentinel')) {
child.remove();
}
});
// Render visible items with absolute positioning
for (let i = this.startIndex; i <= this.endIndex; i++) {
if (this.items[i]) {
const itemEl = this.createItemElement(this.items[i], i);
itemEl.style.position = 'absolute';
itemEl.style.top = (i * this.itemHeight) + 'px';
itemEl.style.height = this.itemHeight + 'px';
itemEl.style.width = '100%';
this.viewport.appendChild(itemEl);
}
}
console.log(`๐ญ Rendered ${this.endIndex - this.startIndex + 1} items of ${this.items.length} total`);
}
}
@tanstack/react-virtual
Modern, lightweight virtualization for React with dynamic heights support
react-window
Popular library by Brian Vaughn, optimized for performance
Virtual Scroller
Framework-agnostic solutions for any JavaScript application
Ion Virtual Scroll
Ionic framework's virtualization for mobile apps
| Use Case | Regular Infinite Scroll | Virtualized Infinite Scroll | Recommendation |
|---|---|---|---|
| < 100 items | โ Simple, fast | โ Overkill | Use regular |
| 100-1,000 items | ๐ก May slow down | โ Consistent performance | Consider virtualization |
| > 1,000 items | โ Performance issues | โ Essential | Must use virtualization |
| Mobile devices | โ Memory constraints | โ Battery efficient | Strongly recommended |
Decision framework for selecting the optimal pagination strategy based on your specific requirements.
| Requirement | Numbered | Load More | Infinite Scroll | Cursor |
|---|---|---|---|---|
| SEO Important | โ Best | โ Poor | โ Poor | ๐ก Medium |
| Mobile First | ๐ก OK | โ Good | โ Best | ๐ก OK |
| Large Dataset (>1M) | โ Slow | ๐ก Medium | ๐ก Medium | โ Best |
| Real-time Data | โ Issues | โ Issues | ๐ก OK | โ Best |
| Direct Page Access | โ Yes | โ No | โ No | โ No |
Client handles pagination logic, server provides data
Server controls pagination, sends HTML or structured data
Initial server render, client takes over for interactions