Skip to main content
fast frontend

Prefetching, Pagination, and Stale Data Strategies

8 min read Chapter 27 of 33

Prefetching, Pagination, and Stale Data Strategies

The Symptom

A user browses the product listing page. They hover over “Wireless Headphones” for 400ms, then click. The product detail page loads with a blank content area for 320ms while GET /api/products/SKU-042 completes. The user saw a loading spinner. The Time to Interactive for the product detail page was 1,200ms from the click.

The 320ms API fetch was entirely predictable. The user signaled intent by hovering. The fetch could have started 400ms before the click.

The Cause

Traditional SPAs fetch data reactively: the route changes, the component mounts, the fetch fires. Every navigation pays the full API latency as blocking time. This is the correct default because prefetching data that is never used wastes bandwidth. But for the most common navigation paths, the hit rate is high enough to justify speculation.

The Baseline

Product listing → product detail navigation:

  • Average hover-to-click time: 420ms
  • API response time (p50): 180ms
  • API response time (p95): 320ms
  • TTI from click (no prefetch): 1,200ms
  • Prefetch accuracy for hover: 72% (72% of hovers result in clicks)

The Fix

Predictive Prefetching on Hover Intent

// SLOW: Fetch on mount (after navigation)
function ProductDetailPage({ productId }: { productId: string }) {
  const [product, setProduct] = useState<Product | null>(null);

  useEffect(() => {
    fetch(`/api/products/${productId}`)
      .then((r) => r.json())
      .then(setProduct);
  }, [productId]);

  if (!product) return <Spinner />;
  return <ProductDetail product={product} />;
}

// FAST: Prefetch on hover intent
const prefetchCache = new Map<string, Promise<Product>>();

function prefetchProduct(productId: string): void {
  if (prefetchCache.has(productId)) return;

  const promise = fetch(`/api/products/${productId}`).then((r) => r.json());
  prefetchCache.set(productId, promise);

  // Evict after 30 seconds to prevent stale data
  setTimeout(() => prefetchCache.delete(productId), 30_000);
}

function ProductCard({ product }: { product: ProductListing }) {
  const hoverTimeout = useRef<ReturnType<typeof setTimeout>>();

  function handlePointerEnter(): void {
    // Wait 150ms to filter out accidental hover-overs
    hoverTimeout.current = setTimeout(() => {
      prefetchProduct(product.id);
    }, 150);
  }

  function handlePointerLeave(): void {
    clearTimeout(hoverTimeout.current);
  }

  return (
    <a
      href={`/product/${product.id}`}
      onPointerEnter={handlePointerEnter}
      onPointerLeave={handlePointerLeave}
    >
      <img src={product.thumbnailUrl} alt={product.name} />
      <h3>{product.name}</h3>
      <span>{formatPrice(product.price)}</span>
    </a>
  );
}

function ProductDetailPage({ productId }: { productId: string }) {
  const [product, setProduct] = useState<Product | null>(null);

  useEffect(() => {
    const cached = prefetchCache.get(productId);
    const fetcher = cached ?? fetch(`/api/products/${productId}`).then((r) => r.json());

    fetcher.then(setProduct);
  }, [productId]);

  if (!product) return <Spinner />;
  return <ProductDetail product={product} />;
}

The 150ms hover delay filters out mouse movements that cross a product card without stopping. Without this delay, every mouse movement over the product grid triggers fetches. With the delay, the prefetch accuracy increases from 34% to 72%.

The prefetchCache is a module-scoped Map with automatic eviction. If the user hovers but does not click, the cached promise is discarded after 30 seconds. If the user clicks, the product detail page consumes the already-resolved promise from the cache, skipping the loading state entirely.

Cursor-Based Pagination

The product listing loads 20 products initially. The user scrolls down. The naive approach: GET /api/products?page=2&limit=20. The problem: offset-based pagination scans and skips rows. Page 10 scans 200 rows and returns 20. As the offset grows, database query time grows linearly.

// SLOW: Offset-based pagination
// GET /api/products?page=10&limit=20
// Server executes: SELECT * FROM products LIMIT 20 OFFSET 180
// Database scans 200 rows, returns 20. Query time: 45ms at page 10.

// FAST: Cursor-based pagination
// GET /api/products?cursor=eyJpZCI6IlNLVS0yMDAifQ&limit=20
// Server executes: SELECT * FROM products WHERE id > 'SKU-200' LIMIT 20
// Database seeks to cursor, returns next 20. Query time: 4ms at any depth.

interface CursorPage<T> {
  items: T[];
  cursor: string | null; // null = last page
  hasMore: boolean;
}

function usePaginatedProducts(category: string) {
  const [pages, setPages] = useState<CursorPage<ProductListing>[]>([]);
  const [cursor, setCursor] = useState<string | null>("");
  const [isLoading, setIsLoading] = useState(false);

  const loadMore = useCallback(async () => {
    if (cursor === null || isLoading) return;
    setIsLoading(true);

    const params = new URLSearchParams({
      category,
      limit: "20",
      fields: "id,name,price,thumbnailUrl,category,inStock",
    });
    if (cursor) params.set("cursor", cursor);

    const response = await fetch(`/api/products?${params}`);
    const page: CursorPage<ProductListing> = await response.json();

    setPages((prev) => [...prev, page]);
    setCursor(page.cursor);
    setIsLoading(false);
  }, [category, cursor, isLoading]);

  const products = useMemo(() => pages.flatMap((p) => p.items), [pages]);

  return { products, loadMore, hasMore: cursor !== null, isLoading };
}

The cursor is an opaque base64-encoded string containing the sort key of the last item in the current page. The server decodes it and uses it in a WHERE clause with an index seek. The query time is constant regardless of how deep into the list the user has scrolled.

Stale-While-Revalidate Data Pattern

The inventory dashboard shows stock levels that update every few minutes. The user navigates away to the order page and returns. Without caching, the stock levels fetch again: 240ms of loading time for data that has probably not changed.

interface CacheEntry<T> {
  data: T;
  timestamp: number;
}

function useStaleWhileRevalidate<T>(
  key: string,
  fetcher: () => Promise<T>,
  maxAge: number = 60_000
): { data: T | null; isRevalidating: boolean } {
  const [data, setData] = useState<T | null>(null);
  const [isRevalidating, setIsRevalidating] = useState(false);

  useEffect(() => {
    const cached = sessionStorage.getItem(key);

    if (cached) {
      const entry: CacheEntry<T> = JSON.parse(cached);
      setData(entry.data); // Display stale data immediately

      const age = Date.now() - entry.timestamp;
      if (age > maxAge) {
        // Stale: revalidate in background
        setIsRevalidating(true);
        fetcher().then((fresh) => {
          setData(fresh);
          sessionStorage.setItem(
            key,
            JSON.stringify({ data: fresh, timestamp: Date.now() })
          );
          setIsRevalidating(false);
        });
      }
    } else {
      // No cache: fetch and store
      fetcher().then((fresh) => {
        setData(fresh);
        sessionStorage.setItem(
          key,
          JSON.stringify({ data: fresh, timestamp: Date.now() })
        );
      });
    }
  }, [key, fetcher, maxAge]);

  return { data, isRevalidating };
}

// Usage in inventory dashboard
function InventoryPanel() {
  const { data: levels, isRevalidating } = useStaleWhileRevalidate(
    'inventory-levels',
    () => fetch('/api/inventory/levels').then((r) => r.json()),
    60_000 // Revalidate after 1 minute
  );

  return (
    <div>
      {isRevalidating && <RevalidatingIndicator />}
      {levels && <InventoryTable levels={levels} />}
    </div>
  );
}

The stale-while-revalidate pattern shows cached data instantly (0ms perceived load time) and refreshes in the background. The isRevalidating flag lets the UI show a subtle indicator (a thin progress bar, not a spinner) while fresh data loads.

For the dashboard: the initial visit fetches from the API (240ms). The return visit shows cached data immediately and revalidates. If the data has not changed, the revalidation response is a 304 Not Modified (64 bytes). If it has changed, the UI updates smoothly without a loading state.

The Proof

MetricNo PrefetchWith Hover PrefetchDelta
TTI (listing → detail)1,200ms680ms-520ms
Prefetch hit rateN/A72%N/A
Wasted prefetchesN/A28% of hovers+28% extra API load
MetricOffset PaginationCursor PaginationDelta
Page 1 query time4ms4ms0ms
Page 10 query time45ms4ms-41ms
Page 50 query time210ms4ms-206ms
MetricNo SWR CacheWith SWRDelta
Return visit load240ms0ms (stale) + 240ms (bg)-240ms perceived
Time to interactive1,100ms320ms-780ms

The CI Lighthouse gate measures Time to Interactive. The prefetch strategy reduces TTI for the product detail page. The size-limit gate from Chapter 2 is not affected because prefetching does not change the JavaScript bundle size. A custom CI metric tracking API response times at the p95 level catches pagination regressions.

The Trade-off

Prefetching has a 28% waste rate: 28% of prefetched data is never viewed. For the product detail API returning 6.8KB (with sparse fields from Section 1), the wasted bandwidth per hover is 6.8KB. At 50,000 daily hovers, wasted bandwidth is 95MB/day. This is acceptable because the perceived performance gain (520ms TTI reduction) has a measurable impact on conversion rate.

The stale-while-revalidate cache uses sessionStorage, which has a 5MB limit per origin. The inventory levels response is 18KB. At this size, the cache stores approximately 280 different API responses before hitting the limit. This is sufficient for session-level caching. For larger datasets, IndexedDB is the correct storage mechanism, but it has async APIs that add complexity.

Cursor-based pagination cannot efficiently jump to an arbitrary page. “Go to page 47” requires scanning from the beginning. For the e-commerce product listing, this is acceptable because users scroll sequentially through results. For the CMS editorial interface where editors need random access to pages, offset-based pagination with a maximum offset limit is the pragmatic choice.