Front-end Engineering Lab

Preload Strategies

Intelligent preloading for instant perceived performance

Preload Strategies

Don't wait for the user to click. Preload intelligently based on behavior, analytics, and heuristics for instant navigation.

🎯 The Goal

Without Preloading:
User clicks link → Start download → Wait 2s → Show page
Total: 2000ms ❌

With Preloading:
User hovers (500ms) → Preload in background
User clicks → Already loaded → Show page instantly
Total: 0ms ✅

📊 Preload Types

StrategyWhenUse CaseHit Rate
HoverMouse enters linkDesktop80-90%
ViewportLink visibleAll devices60-70%
IntentHover + delayDesktop90-95%
PredictiveAnalyticsAll70-80%
IdleBrowser idleAll100% (eventual)
SequentialCurrent → NextOnboarding100%

🎯 Pattern 1: Hover Preload

Basic Hover

import { useRouter } from 'next/router';

function Navigation() {
  const router = useRouter();
  
  const prefetch = (href: string) => {
    router.prefetch(href);
  };
  
  return (
    <nav>
      <a
        href="/dashboard"
        onMouseEnter={() => prefetch('/dashboard')}
      >
        Dashboard
      </a>
      
      <a
        href="/settings"
        onMouseEnter={() => prefetch('/settings')}
      >
        Settings
      </a>
    </nav>
  );
}

With Delay (Intent-Based)

function PrefetchLink({ href, children }: { href: string; children: React.ReactNode }) {
  const router = useRouter();
  const timerRef = useRef<NodeJS.Timeout>();
  
  const handleMouseEnter = () => {
    // Wait 100ms to confirm intent
    timerRef.current = setTimeout(() => {
      router.prefetch(href);
    }, 100);
  };
  
  const handleMouseLeave = () => {
    // User moved away, cancel prefetch
    if (timerRef.current) {
      clearTimeout(timerRef.current);
    }
  };
  
  return (
    <a
      href={href}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
    >
      {children}
    </a>
  );
}

👁️ Pattern 2: Viewport Preload

Intersection Observer

import { useInView } from 'react-intersection-observer';

function PrefetchLink({ href, children }: { href: string; children: React.ReactNode }) {
  const router = useRouter();
  const { ref, inView } = useInView({
    triggerOnce: true,
    rootMargin: '100px' // Prefetch 100px before visible
  });
  
  useEffect(() => {
    if (inView) {
      router.prefetch(href);
    }
  }, [inView, href]);
  
  return (
    <a ref={ref} href={href}>
      {children}
    </a>
  );
}

Bulk Viewport Prefetch

function ProductGrid({ products }: { products: Product[] }) {
  const { ref, inView } = useInView({ triggerOnce: true });
  
  useEffect(() => {
    if (inView) {
      // Prefetch first 6 products
      products.slice(0, 6).forEach(product => {
        import(`./products/${product.id}`);
      });
    }
  }, [inView, products]);
  
  return (
    <div ref={ref} className="grid">
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

🔮 Pattern 3: Predictive Preload

Analytics-Based

// Track user navigation patterns
const navigationPatterns = {
  '/': ['/products', '/about'], // 80% go to products, 15% to about
  '/products': ['/products/:id', '/cart'], // 90% to product, 5% to cart
  '/cart': ['/checkout'], // 95% to checkout
};

function usePredictivePrefetch() {
  const router = useRouter();
  const currentPath = router.pathname;
  
  useEffect(() => {
    const likelyNextPages = navigationPatterns[currentPath] || [];
    
    // Prefetch likely next pages
    likelyNextPages.forEach(page => {
      router.prefetch(page);
    });
  }, [currentPath]);
}

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

Machine Learning Based

interface PredictionModel {
  predict(currentPage: string, userFeatures: UserFeatures): string[];
}

function useMLPrefetch(model: PredictionModel) {
  const router = useRouter();
  const { user } = useAuth();
  
  useEffect(() => {
    const predictedPages = model.predict(router.pathname, {
      userId: user.id,
      previousPages: router.history,
      timeOnPage: Date.now() - router.enteredAt,
      scrollDepth: window.scrollY / document.body.scrollHeight
    });
    
    // Prefetch top 3 predictions
    predictedPages.slice(0, 3).forEach(page => {
      router.prefetch(page);
    });
  }, [router.pathname]);
}

🎨 Pattern 4: Idle Preload

requestIdleCallback

function useIdlePrefetch(routes: string[]) {
  useEffect(() => {
    if ('requestIdleCallback' in window) {
      const id = requestIdleCallback(
        () => {
          // Prefetch during idle time
          routes.forEach(route => {
            import(`./pages/${route}`);
          });
        },
        { timeout: 2000 }
      );
      
      return () => cancelIdleCallback(id);
    } else {
      // Fallback for browsers without requestIdleCallback
      const timer = setTimeout(() => {
        routes.forEach(route => {
          import(`./pages/${route}`);
        });
      }, 1000);
      
      return () => clearTimeout(timer);
    }
  }, [routes]);
}

// Usage
function App() {
  useIdlePrefetch(['/dashboard', '/settings', '/profile']);
  
  return <Routes>...</Routes>;
}

🔄 Pattern 5: Sequential Preload

Onboarding Flow

function OnboardingStep1() {
  useEffect(() => {
    // Preload next step immediately
    import('./OnboardingStep2');
  }, []);
  
  return (
    <div>
      <h1>Step 1</h1>
      <button onClick={() => router.push('/onboarding/step2')}>
        Next
      </button>
    </div>
  );
}

function OnboardingStep2() {
  useEffect(() => {
    // Preload next step
    import('./OnboardingStep3');
  }, []);
  
  return (
    <div>
      <h1>Step 2</h1>
      <button onClick={() => router.push('/onboarding/step3')}>
        Next
      </button>
    </div>
  );
}

Wizard Pattern

const wizardSteps = [
  () => import('./Step1'),
  () => import('./Step2'),
  () => import('./Step3'),
  () => import('./Step4')
];

function Wizard() {
  const [currentStep, setCurrentStep] = useState(0);
  
  useEffect(() => {
    // Preload next step
    if (currentStep < wizardSteps.length - 1) {
      wizardSteps[currentStep + 1]();
    }
  }, [currentStep]);
  
  const Step = lazy(wizardSteps[currentStep]);
  
  return (
    <Suspense fallback={<Spinner />}>
      <Step onNext={() => setCurrentStep(s => s + 1)} />
    </Suspense>
  );
}

🎯 Pattern 6: Priority-Based Preload

interface PreloadTask {
  route: string;
  priority: number;
  condition?: () => boolean;
}

class PreloadScheduler {
  private queue: PreloadTask[] = [];
  private loading = new Set<string>();
  
  add(task: PreloadTask) {
    this.queue.push(task);
    this.queue.sort((a, b) => b.priority - a.priority);
    this.process();
  }
  
  private async process() {
    while (this.queue.length > 0) {
      const task = this.queue.shift()!;
      
      // Check condition
      if (task.condition && !task.condition()) {
        continue;
      }
      
      // Skip if already loading
      if (this.loading.has(task.route)) {
        continue;
      }
      
      this.loading.add(task.route);
      
      try {
        await import(`./pages/${task.route}`);
        console.log(`Preloaded: ${task.route}`);
      } catch (error) {
        console.error(`Failed to preload: ${task.route}`, error);
      } finally {
        this.loading.delete(task.route);
      }
    }
  }
}

// Usage
const scheduler = new PreloadScheduler();

// High priority (user hovering)
scheduler.add({ route: '/dashboard', priority: 100 });

// Medium priority (visible in viewport)
scheduler.add({ route: '/settings', priority: 50 });

// Low priority (idle time)
scheduler.add({ route: '/profile', priority: 10 });

// Conditional
scheduler.add({
  route: '/admin',
  priority: 80,
  condition: () => user.role === 'admin'
});

📊 Measuring Preload Effectiveness

interface PreloadMetrics {
  prefetched: number;
  hits: number;
  misses: number;
  hitRate: number;
}

class PreloadTracker {
  private metrics: PreloadMetrics = {
    prefetched: 0,
    hits: 0,
    misses: 0,
    hitRate: 0
  };
  
  private prefetchedRoutes = new Set<string>();
  
  onPrefetch(route: string) {
    this.prefetchedRoutes.add(route);
    this.metrics.prefetched++;
  }
  
  onNavigate(route: string) {
    if (this.prefetchedRoutes.has(route)) {
      this.metrics.hits++;
      console.log('✅ Prefetch hit:', route);
    } else {
      this.metrics.misses++;
      console.log('❌ Prefetch miss:', route);
    }
    
    this.metrics.hitRate = this.metrics.hits / (this.metrics.hits + this.metrics.misses);
    
    // Send to analytics
    analytics.track('prefetch_metrics', this.metrics);
  }
  
  getMetrics() {
    return this.metrics;
  }
}

const tracker = new PreloadTracker();

// Track prefetches
router.prefetch('/dashboard');
tracker.onPrefetch('/dashboard');

// Track navigation
router.push('/dashboard');
tracker.onNavigate('/dashboard');

// Check metrics
console.log('Hit rate:', tracker.getMetrics().hitRate); // 0.85 (85%)

🎨 Resource Hints

Preload Critical Resources

<head>
  <!-- Preload critical JS -->
  <link rel="preload" href="/main.js" as="script">
  
  <!-- Preload critical CSS -->
  <link rel="preload" href="/critical.css" as="style">
  
  <!-- Preload fonts -->
  <link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
</head>

Prefetch Next Pages

<!-- Prefetch likely next pages (low priority) -->
<link rel="prefetch" href="/dashboard.js" as="script">
<link rel="prefetch" href="/settings.js" as="script">

DNS Prefetch

<!-- Resolve DNS early for external resources -->
<link rel="dns-prefetch" href="https://api.example.com">
<link rel="dns-prefetch" href="https://cdn.example.com">

Preconnect

<!-- Establish connection early -->
<link rel="preconnect" href="https://api.example.com">
<link rel="preconnect" href="https://fonts.googleapis.com">

npm install quicklink
import { prefetch } from 'quicklink';

// Automatically prefetch all visible links
useEffect(() => {
  prefetch();
}, []);

// Or specific links
useEffect(() => {
  prefetch(['/dashboard', '/settings']);
}, []);

📚 Best Practices

1. Start with Hover

// Highest ROI, easiest to implement
<a onMouseEnter={() => prefetch('/page')}>Link</a>

2. Respect Data Saver

if (!navigator.connection?.saveData) {
  // Only prefetch if user hasn't enabled data saver
  prefetch('/dashboard');
}

3. Monitor Hit Rate

// Aim for > 80% hit rate
if (hitRate < 0.8) {
  console.warn('Prefetch strategy needs improvement');
}

4. Limit Concurrent Prefetches

// Max 3 concurrent prefetches
const maxConcurrent = 3;
let loading = 0;

async function prefetch(route: string) {
  if (loading >= maxConcurrent) {
    await waitForSlot();
  }
  
  loading++;
  await import(route);
  loading--;
}

🏢 Real-World Examples

- Hover prefetch (search results)
- Predictive prefetch (autocomplete)
- Instant prefetch (I'm Feeling Lucky)
Result: < 100ms navigation

GitHub

- Hover prefetch (repository links)
- Viewport prefetch (trending repos)
- Idle prefetch (popular pages)
Result: Instant navigation feel

📚 Key Takeaways

  1. Hover prefetch - 80-90% hit rate on desktop
  2. Viewport prefetch - Works on mobile too
  3. Predictive - Use analytics for common paths
  4. Idle time - Prefetch popular pages
  5. Respect data saver - Check navigator.connection
  6. Monitor hit rate - Aim for > 80%
  7. Test on 3G - Ensure prefetch doesn't hurt

Preloading typically makes navigation feel instant (< 100ms perceived load time). Start with hover! 🚀

On this page