Front-end Engineering Lab

Preload Strategies

Intelligent preloading for instant perceived performance

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