Front-end Engineering Lab

Prefetching Strategies

Load data before the user needs it for instant perceived performance

The fastest request is the one you never have to make. This guide covers smart prefetching patterns.

🎯 Goals

What we want:
✅ Zero perceived latency
✅ Instant page transitions
✅ Predictive loading
✅ Efficient network usage
✅ No wasted bandwidth

🎯 Pattern 1: Hover Prefetch

Load data when user hovers over link.

import { useQueryClient } from '@tanstack/react-query';

function ProductCard({ product }: { product: Product }) {
  const queryClient = useQueryClient();
  
  const prefetchProduct = () => {
    queryClient.prefetchQuery({
      queryKey: ['product', product.id],
      queryFn: () => fetchProduct(product.id),
      staleTime: 60000, // Cache for 1 minute
    });
  };
  
  return (
    <Link
      to={`/products/${product.id}`}
      onMouseEnter={prefetchProduct}
      onFocus={prefetchProduct} // Also prefetch on keyboard focus
    >
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>${product.price}</p>
    </Link>
  );
}

// When user hovers, data is prefetched
// When user clicks, data is already cached!
// Result: Instant page load ⚡
import { Link } from 'react-router-dom';

function PrefetchLink({ to, children, ...props }: LinkProps) {
  const queryClient = useQueryClient();
  
  const handleHover = () => {
    // Extract route and prefetch relevant data
    if (to.startsWith('/products/')) {
      const id = to.split('/')[2];
      queryClient.prefetchQuery({
        queryKey: ['product', id],
        queryFn: () => fetchProduct(id)
      });
    }
  };
  
  return (
    <Link 
      to={to} 
      onMouseEnter={handleHover}
      onFocus={handleHover}
      {...props}
    >
      {children}
    </Link>
  );
}

// Usage
<PrefetchLink to="/products/123">
  View Product
</PrefetchLink>

Pros:

  • ✅ High intent (user hovering = likely click)
  • ✅ Instant perceived performance
  • ✅ Easy to implement

Cons:

  • ⚠️ Wasted bandwidth if user doesn't click
  • ⚠️ Not mobile-friendly (no hover)

📜 Pattern 2: Viewport Prefetch

Prefetch when elements enter viewport.

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

function ProductCard({ product }: { product: Product }) {
  const queryClient = useQueryClient();
  const { ref, inView } = useInView({
    triggerOnce: true, // Only prefetch once
    threshold: 0.1, // When 10% visible
  });
  
  useEffect(() => {
    if (inView) {
      queryClient.prefetchQuery({
        queryKey: ['product', product.id],
        queryFn: () => fetchProduct(product.id)
      });
    }
  }, [inView, product.id, queryClient]);
  
  return (
    <div ref={ref}>
      <Link to={`/products/${product.id}`}>
        <img src={product.image} alt={product.name} />
        <h3>{product.name}</h3>
      </Link>
    </div>
  );
}

Bulk Prefetch

function ProductGrid({ products }: { products: Product[] }) {
  const queryClient = useQueryClient();
  const { ref, inView } = useInView();
  
  useEffect(() => {
    if (inView) {
      // Prefetch all visible products
      products.slice(0, 6).forEach(product => {
        queryClient.prefetchQuery({
          queryKey: ['product', product.id],
          queryFn: () => fetchProduct(product.id)
        });
      });
    }
  }, [inView, products, queryClient]);
  
  return (
    <div ref={ref} className="grid">
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

Pros:

  • ✅ Only prefetch what user can see
  • ✅ Works on mobile
  • ✅ Lower waste than hover

Cons:

  • ⚠️ Still some wasted bandwidth
  • ⚠️ Requires intersection observer

🔮 Pattern 3: Predictive Prefetch

Use analytics to predict next page.

interface NavigationPattern {
  from: string;
  to: string;
  probability: number;
}

// Analytics data
const patterns: NavigationPattern[] = [
  { from: '/products', to: '/cart', probability: 0.6 },
  { from: '/cart', to: '/checkout', probability: 0.8 },
  { from: '/products/:id', to: '/products/:related', probability: 0.4 },
];

function usePredictivePrefetch(currentPath: string) {
  const queryClient = useQueryClient();
  
  useEffect(() => {
    // Find likely next pages
    const likely = patterns
      .filter(p => p.from === currentPath && p.probability > 0.5)
      .sort((a, b) => b.probability - a.probability);
    
    // Prefetch top 2 likely pages
    likely.slice(0, 2).forEach(pattern => {
      prefetchRouteData(pattern.to, queryClient);
    });
  }, [currentPath, queryClient]);
}

function prefetchRouteData(route: string, queryClient: QueryClient) {
  if (route === '/cart') {
    queryClient.prefetchQuery({
      queryKey: ['cart'],
      queryFn: fetchCart
    });
  } else if (route === '/checkout') {
    queryClient.prefetchQuery({
      queryKey: ['checkout'],
      queryFn: fetchCheckoutData
    });
  }
}

// Usage in app
function App() {
  const location = useLocation();
  usePredictivePrefetch(location.pathname);
  
  return <Routes>...</Routes>;
}

Pros:

  • ✅ Data-driven prefetching
  • ✅ High accuracy with good data
  • ✅ Adapts to user behavior

Cons:

  • ⚠️ Requires analytics
  • ⚠️ Complex implementation
  • ⚠️ May prefetch wrong pages

🚀 Pattern 4: Route-Based Prefetch

Prefetch data for next route on current route.

// On product list page, prefetch first product
function ProductListPage() {
  const queryClient = useQueryClient();
  const { data: products } = useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts
  });
  
  useEffect(() => {
    if (products && products.length > 0) {
      // Prefetch first 3 products
      products.slice(0, 3).forEach(product => {
        queryClient.prefetchQuery({
          queryKey: ['product', product.id],
          queryFn: () => fetchProduct(product.id)
        });
      });
    }
  }, [products, queryClient]);
  
  return <ProductList products={products} />;
}

Next.js Example

// pages/products/index.tsx
export async function getStaticProps() {
  const products = await fetchProducts();
  
  // Prefetch first product data
  const firstProduct = await fetchProduct(products[0].id);
  
  return {
    props: {
      products,
      prefetchedProduct: firstProduct
    },
    revalidate: 60
  };
}

📱 Pattern 5: Idle Prefetch

Prefetch during idle time.

function useIdlePrefetch() {
  const queryClient = useQueryClient();
  
  useEffect(() => {
    // Use requestIdleCallback
    const prefetch = () => {
      if ('requestIdleCallback' in window) {
        requestIdleCallback(() => {
          // Prefetch popular products
          queryClient.prefetchQuery({
            queryKey: ['products', 'popular'],
            queryFn: fetchPopularProducts
          });
          
          // Prefetch user data
          queryClient.prefetchQuery({
            queryKey: ['user'],
            queryFn: fetchUser
          });
        }, { timeout: 2000 });
      } else {
        // Fallback: use setTimeout
        setTimeout(() => {
          queryClient.prefetchQuery({
            queryKey: ['products', 'popular'],
            queryFn: fetchPopularProducts
          });
        }, 1000);
      }
    };
    
    prefetch();
  }, [queryClient]);
}

// Usage
function App() {
  useIdlePrefetch();
  
  return <Routes>...</Routes>;
}

Pros:

  • ✅ Doesn't block UI
  • ✅ Uses available resources
  • ✅ Good for static content

Cons:

  • ⚠️ Unpredictable timing
  • ⚠️ May never run on slow devices

Prefetch when link is in viewport.

import { Link as RouterLink } from 'react-router-dom';
import { useInView } from 'react-intersection-observer';

interface PrefetchLinkProps {
  to: string;
  prefetch?: 'hover' | 'viewport' | 'intent';
  children: React.ReactNode;
}

function PrefetchLink({ 
  to, 
  prefetch = 'intent', 
  children 
}: PrefetchLinkProps) {
  const queryClient = useQueryClient();
  const { ref, inView } = useInView({ triggerOnce: true });
  
  const handlePrefetch = useCallback(() => {
    // Extract data to prefetch from route
    const dataKey = extractDataKey(to);
    if (dataKey) {
      queryClient.prefetchQuery({
        queryKey: [dataKey],
        queryFn: () => fetchData(dataKey)
      });
    }
  }, [to, queryClient]);
  
  // Viewport prefetch
  useEffect(() => {
    if (prefetch === 'viewport' && inView) {
      handlePrefetch();
    }
  }, [prefetch, inView, handlePrefetch]);
  
  // Intent prefetch (hover + small delay)
  const [hovered, setHovered] = useState(false);
  useEffect(() => {
    if (prefetch === 'intent' && hovered) {
      const timer = setTimeout(handlePrefetch, 100); // 100ms delay
      return () => clearTimeout(timer);
    }
  }, [prefetch, hovered, handlePrefetch]);
  
  return (
    <RouterLink
      ref={ref}
      to={to}
      onMouseEnter={prefetch === 'hover' || prefetch === 'intent' 
        ? () => setHovered(true) 
        : undefined
      }
      onMouseLeave={prefetch === 'intent' 
        ? () => setHovered(false) 
        : undefined
      }
    >
      {children}
    </RouterLink>
  );
}

// Usage
<PrefetchLink to="/products/123" prefetch="hover">
  View Product
</PrefetchLink>

🎨 Pattern 7: Progressive Prefetch

Prefetch in priority order.

class PrefetchQueue {
  private queue: Array<() => Promise<void>> = [];
  private running = 0;
  private maxConcurrent = 2;
  
  add(priority: number, task: () => Promise<void>) {
    this.queue.push({ priority, task });
    this.queue.sort((a, b) => b.priority - a.priority);
    this.process();
  }
  
  private async process() {
    while (this.queue.length > 0 && this.running < this.maxConcurrent) {
      const item = this.queue.shift();
      if (!item) break;
      
      this.running++;
      
      try {
        await item.task();
      } catch (error) {
        console.error('Prefetch failed:', error);
      } finally {
        this.running--;
        this.process();
      }
    }
  }
}

const prefetchQueue = new PrefetchQueue();

// Usage
function ProductList({ products }: { products: Product[] }) {
  const queryClient = useQueryClient();
  
  useEffect(() => {
    products.forEach((product, index) => {
      const priority = 100 - index; // First products = higher priority
      
      prefetchQueue.add(priority, async () => {
        await queryClient.prefetchQuery({
          queryKey: ['product', product.id],
          queryFn: () => fetchProduct(product.id)
        });
      });
    });
  }, [products, queryClient]);
  
  return <div>...</div>;
}

📊 Comparison Table

PatternWhen to UseMobileAccuracyWaste
HoverDesktop links⭐⭐⭐⭐Medium
ViewportLong lists⭐⭐⭐Medium
PredictiveKnown patterns⭐⭐⭐⭐⭐Low
Route-BasedNavigation flows⭐⭐⭐⭐⭐Low
IdleStatic content⭐⭐⭐Medium
IntentAll links⭐⭐⭐⭐Low
ProgressiveMany items⭐⭐⭐Low

🏢 Real-World Examples

Google

// Instant search results
// Prefetch on hover (Google Search)
// Predictive page load

Amazon

// Hover prefetch on products
// Related products prefetched
// Aggressive cart prefetch

YouTube

// Prefetch next video
// Predictive based on watch history
// Thumbnail sprites prefetched

📚 Key Takeaways

  1. Hover prefetch for desktop (80% click rate after hover)
  2. Viewport prefetch for mobile
  3. Predictive when you have data
  4. Route-based for known flows
  5. Monitor waste - Don't prefetch everything
  6. Respect data saver mode
  7. Test on slow networks - Does prefetch help or hurt?

Prefetch intelligently, not aggressively. Measure the impact on actual users.

On this page