Front-end Engineering Lab

Pagination Strategies

Efficient patterns for loading large datasets

Loading large datasets efficiently is critical for performance. This guide covers proven pagination patterns.

🎯 Goals

What we want:
✅ Fast initial load
✅ Smooth user experience
✅ Efficient network usage
✅ SEO-friendly (when needed)
✅ Handle millions of items

📄 Pattern 1: Offset-Based Pagination

Traditional page numbers (1, 2, 3...).

interface PaginationParams {
  page: number;
  pageSize: number;
}

interface PaginatedResponse<T> {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
}

async function fetchProducts(
  params: PaginationParams
): Promise<PaginatedResponse<Product>> {
  const offset = (params.page - 1) * params.pageSize;
  
  const response = await fetch(
    `/api/products?offset=${offset}&limit=${params.pageSize}`
  );
  
  return response.json();
}

// Usage
const page1 = await fetchProducts({ page: 1, pageSize: 20 });
// Returns items 0-19

const page2 = await fetchProducts({ page: 2, pageSize: 20 });
// Returns items 20-39

React Component

function ProductList() {
  const [page, setPage] = useState(1);
  const pageSize = 20;
  
  const { data, loading } = useQuery({
    queryKey: ['products', page, pageSize],
    queryFn: () => fetchProducts({ page, pageSize })
  });
  
  if (loading) return <div>Loading...</div>;
  
  return (
    <div>
      {data?.data.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
      
      <div className="pagination">
        <button 
          onClick={() => setPage(p => p - 1)}
          disabled={page === 1}
        >
          Previous
        </button>
        
        <span>Page {page} of {data?.totalPages}</span>
        
        <button 
          onClick={() => setPage(p => p + 1)}
          disabled={page === data?.totalPages}
        >
          Next
        </button>
      </div>
    </div>
  );
}

Pros:

  • ✅ Simple to implement
  • ✅ Jump to any page
  • ✅ Show total pages
  • ✅ SEO-friendly URLs

Cons:

  • ⚠️ Slow for large offsets (SELECT * FROM ... OFFSET 1000000)
  • ⚠️ Inconsistent when data changes (items can skip/duplicate)
  • ⚠️ Not suitable for real-time data

🔄 Pattern 2: Cursor-Based Pagination

Use a cursor (pointer) to next items.

interface CursorPaginationParams {
  cursor?: string;
  limit: number;
}

interface CursorPaginatedResponse<T> {
  data: T[];
  nextCursor: string | null;
  hasMore: boolean;
}

async function fetchProducts(
  params: CursorPaginationParams
): Promise<CursorPaginatedResponse<Product>> {
  const url = params.cursor
    ? `/api/products?cursor=${params.cursor}&limit=${params.limit}`
    : `/api/products?limit=${params.limit}`;
  
  const response = await fetch(url);
  return response.json();
}

// Backend implementation
app.get('/api/products', async (req, res) => {
  const limit = parseInt(req.query.limit) || 20;
  const cursor = req.query.cursor;
  
  let query = 'SELECT * FROM products';
  
  if (cursor) {
    // Cursor is encoded product ID
    query += ` WHERE id > '${cursor}'`;
  }
  
  query += ` ORDER BY id ASC LIMIT ${limit + 1}`;
  
  const products = await db.query(query);
  
  const hasMore = products.length > limit;
  const data = hasMore ? products.slice(0, -1) : products;
  const nextCursor = hasMore ? data[data.length - 1].id : null;
  
  res.json({
    data,
    nextCursor,
    hasMore
  });
});

React Component

function ProductList() {
  const [cursor, setCursor] = useState<string | null>(null);
  const [products, setProducts] = useState<Product[]>([]);
  
  const { data, loading } = useQuery({
    queryKey: ['products', cursor],
    queryFn: () => fetchProducts({ cursor: cursor || undefined, limit: 20 })
  });
  
  useEffect(() => {
    if (data) {
      setProducts(prev => [...prev, ...data.data]);
    }
  }, [data]);
  
  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
      
      {data?.hasMore && (
        <button 
          onClick={() => setCursor(data.nextCursor)}
          disabled={loading}
        >
          Load More
        </button>
      )}
    </div>
  );
}

Pros:

  • ✅ Fast regardless of position
  • ✅ Consistent (no skips/duplicates)
  • ✅ Works with real-time data
  • ✅ Efficient database queries

Cons:

  • ⚠️ Can't jump to arbitrary page
  • ⚠️ Harder to implement
  • ⚠️ Less SEO-friendly

♾️ Pattern 3: Infinite Scroll

Load more as user scrolls.

import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';

function InfiniteProductList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['products'],
    queryFn: ({ pageParam = null }) => 
      fetchProducts({ cursor: pageParam, limit: 20 }),
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });
  
  const { ref, inView } = useInView();
  
  // Load more when sentinel comes into view
  useEffect(() => {
    if (inView && hasNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage, fetchNextPage]);
  
  const products = data?.pages.flatMap(page => page.data) ?? [];
  
  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
      
      {/* Sentinel element */}
      <div ref={ref} className="loading-sentinel">
        {isFetchingNextPage && <Spinner />}
      </div>
      
      {!hasNextPage && <div>No more products</div>}
    </div>
  );
}

Manual Scroll Detection

function InfiniteProductList() {
  const [page, setPage] = useState(1);
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  
  useEffect(() => {
    const handleScroll = () => {
      if (loading || !hasMore) return;
      
      const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
      
      // Trigger when 80% scrolled
      if (scrollTop + clientHeight >= scrollHeight * 0.8) {
        loadMore();
      }
    };
    
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, [loading, hasMore]);
  
  async function loadMore() {
    setLoading(true);
    
    const data = await fetchProducts({ page, pageSize: 20 });
    
    setProducts(prev => [...prev, ...data.data]);
    setPage(prev => prev + 1);
    setHasMore(data.page < data.totalPages);
    setLoading(false);
  }
  
  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
      {loading && <Spinner />}
    </div>
  );
}

Pros:

  • ✅ Seamless UX
  • ✅ No click required
  • ✅ Great for mobile
  • ✅ Addictive scrolling

Cons:

  • ⚠️ Hard to reach footer
  • ⚠️ Memory issues with thousands of items
  • ⚠️ Back button challenges
  • ⚠️ SEO challenges

🪟 Pattern 4: Virtual Scrolling

Render only visible items.

import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualProductList() {
  const parentRef = useRef<HTMLDivElement>(null);
  
  const [products, setProducts] = useState<Product[]>([]);
  
  const virtualizer = useVirtualizer({
    count: products.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100, // Estimated item height
    overscan: 5, // Render 5 extra items
  });
  
  // Fetch more when nearing end
  useEffect(() => {
    const [lastItem] = [...virtualizer.getVirtualItems()].reverse();
    
    if (!lastItem) return;
    
    if (
      lastItem.index >= products.length - 1 &&
      hasNextPage &&
      !isFetchingNextPage
    ) {
      fetchNextPage();
    }
  }, [
    virtualizer.getVirtualItems(),
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  ]);
  
  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualItem) => {
          const product = products[virtualItem.index];
          
          return (
            <div
              key={virtualItem.key}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                height: `${virtualItem.size}px`,
                transform: `translateY(${virtualItem.start}px)`,
              }}
            >
              <ProductCard product={product} />
            </div>
          );
        })}
      </div>
    </div>
  );
}

Pros:

  • ✅ Handle millions of items
  • ✅ Constant memory usage
  • ✅ Smooth scrolling
  • ✅ Great performance

Cons:

  • ⚠️ Complex implementation
  • ⚠️ Fixed/estimated heights required
  • ⚠️ Accessibility challenges

🔢 Pattern 5: Load More Button

Manual pagination control.

function ProductList() {
  const [products, setProducts] = useState<Product[]>([]);
  const [cursor, setCursor] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  
  const loadMore = async () => {
    setLoading(true);
    
    const data = await fetchProducts({ 
      cursor: cursor || undefined, 
      limit: 20 
    });
    
    setProducts(prev => [...prev, ...data.data]);
    setCursor(data.nextCursor);
    setHasMore(data.hasMore);
    setLoading(false);
  };
  
  useEffect(() => {
    loadMore(); // Initial load
  }, []);
  
  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
      
      {hasMore && (
        <button onClick={loadMore} disabled={loading}>
          {loading ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

Pros:

  • ✅ User control
  • ✅ Simple implementation
  • ✅ Good for accessibility
  • ✅ SEO-friendly with SSR

Cons:

  • ⚠️ Requires user action
  • ⚠️ Less engaging than infinite scroll

🎯 Pattern 6: Search Result Pagination

Handle filtered/searched data.

function SearchResults() {
  const [query, setQuery] = useState('');
  const [page, setPage] = useState(1);
  const [debouncedQuery] = useDebounce(query, 300);
  
  const { data, loading } = useQuery({
    queryKey: ['search', debouncedQuery, page],
    queryFn: () => searchProducts({ query: debouncedQuery, page, pageSize: 20 }),
    enabled: debouncedQuery.length > 0,
  });
  
  // Reset page when query changes
  useEffect(() => {
    setPage(1);
  }, [debouncedQuery]);
  
  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search products..."
      />
      
      {loading && <Spinner />}
      
      {data && (
        <>
          <div className="results">
            {data.data.map(product => (
              <ProductCard key={product.id} product={product} />
            ))}
          </div>
          
          {data.totalPages > 1 && (
            <Pagination
              current={page}
              total={data.totalPages}
              onChange={setPage}
            />
          )}
        </>
      )}
    </div>
  );
}

📊 Comparison Table

PatternBest ForComplexityPerformanceSEO
OffsetSmall datasets, SEOLowMedium⭐⭐⭐⭐⭐
CursorLarge datasets, real-timeMedium⭐⭐⭐⭐⭐⭐⭐
Infinite ScrollSocial feeds, mobileMedium⭐⭐⭐⭐
Virtual ScrollHuge lists (10K+ items)High⭐⭐⭐⭐⭐
Load MoreGeneral useLow⭐⭐⭐⭐⭐⭐⭐

🏢 Real-World Examples

Twitter

// Cursor-based pagination
// Infinite scroll
// Virtual scrolling for long threads

Amazon

// Offset pagination for products
// SEO-friendly page numbers
// Load more for reviews

Instagram

// Infinite scroll
// Cursor-based
// Aggressive prefetching

LinkedIn

// Infinite scroll for feed
// Offset pagination for search
// Load more for connections

📚 Key Takeaways

  1. Offset for SEO and small datasets
  2. Cursor for large datasets and real-time
  3. Infinite scroll for engagement (feeds)
  4. Virtual scroll for huge lists
  5. Load more for control and accessibility
  6. Always show loading states
  7. Prefetch next page for instant UX

Choose based on your data size, SEO needs, and user behavior.

On this page