Front-end Engineering Lab

Route-Based Splitting

Lazy load routes for the biggest performance impact

Route-Based Splitting

The easiest and most impactful code splitting strategy. Split your app by routes - users only download the page they visit.

🎯 The Impact

Without Route Splitting:
User visits /         → Downloads EVERYTHING (2.5 MB)
User visits /about    → Already has it (but wasted bandwidth)
User visits /dashboard → Already has it (but wasted bandwidth)

With Route Splitting:
User visits /         → Downloads 150 KB ✅
User visits /about    → Downloads 80 KB more
User visits /dashboard → Downloads 200 KB more

Total downloaded: 430 KB instead of 2.5 MB (83% savings!)

⚛️ React Router + Lazy

Basic Setup

import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// Lazy load each route
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Profile = lazy(() => import('./pages/Profile'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<PageLoader />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/profile" element={<Profile />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

With Loading States

function PageLoader() {
  return (
    <div className="page-loader">
      <div className="spinner" />
      <p>Loading page...</p>
    </div>
  );
}

// Or skeleton loader
function PageLoader() {
  return (
    <div className="skeleton-page">
      <div className="skeleton-header" />
      <div className="skeleton-content" />
      <div className="skeleton-sidebar" />
    </div>
  );
}

With Error Boundary

import { ErrorBoundary } from 'react-error-boundary';

function App() {
  return (
    <BrowserRouter>
      <ErrorBoundary 
        fallback={<ErrorPage />}
        onError={(error) => console.error('Route load error:', error)}
      >
        <Suspense fallback={<PageLoader />}>
          <Routes>
            <Route path="/" element={<Home />} />
            <Route path="/dashboard" element={<Dashboard />} />
          </Routes>
        </Suspense>
      </ErrorBoundary>
    </BrowserRouter>
  );
}

function ErrorPage() {
  return (
    <div>
      <h1>Failed to load page</h1>
      <button onClick={() => window.location.reload()}>
        Retry
      </button>
    </div>
  );
}

🔥 Next.js (Automatic Route Splitting)

Next.js automatically splits by routes - zero config needed!

// pages/index.tsx → Separate chunk
export default function Home() {
  return <div>Home</div>;
}

// pages/about.tsx → Separate chunk
export default function About() {
  return <div>About</div>;
}

// pages/dashboard.tsx → Separate chunk
export default function Dashboard() {
  return <div>Dashboard</div>;
}

// Each page is a separate bundle automatically! ✅

App Router (Next.js 13+)

// app/page.tsx → Separate chunk
export default function Home() {
  return <div>Home</div>;
}

// app/about/page.tsx → Separate chunk
export default function About() {
  return <div>About</div>;
}

// app/dashboard/page.tsx → Separate chunk
export default function Dashboard() {
  return <div>Dashboard</div>;
}

🎨 Advanced Patterns

Nested Route Splitting

const Dashboard = lazy(() => import('./pages/Dashboard'));

// Nested routes also split
const DashboardLayout = lazy(() => import('./layouts/DashboardLayout'));
const Analytics = lazy(() => import('./pages/dashboard/Analytics'));
const Reports = lazy(() => import('./pages/dashboard/Reports'));
const Settings = lazy(() => import('./pages/dashboard/Settings'));

function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      
      <Route path="/dashboard" element={
        <Suspense fallback={<PageLoader />}>
          <DashboardLayout />
        </Suspense>
      }>
        <Route index element={
          <Suspense fallback={<Spinner />}>
            <Analytics />
          </Suspense>
        } />
        <Route path="reports" element={
          <Suspense fallback={<Spinner />}>
            <Reports />
          </Suspense>
        } />
        <Route path="settings" element={
          <Suspense fallback={<Spinner />}>
            <Settings />
          </Suspense>
        } />
      </Route>
    </Routes>
  );
}

Named Chunks

// Name your chunks for better debugging
const Dashboard = lazy(() => 
  import(
    /* webpackChunkName: "dashboard" */
    './pages/Dashboard'
  )
);

const Analytics = lazy(() => 
  import(
    /* webpackChunkName: "analytics" */
    './pages/Analytics'
  )
);

// Results in:
// dashboard.chunk.js
// analytics.chunk.js

Retry on Failure

function lazyWithRetry(importFn: () => Promise<any>, retries = 3) {
  return lazy(async () => {
    for (let i = 0; i < retries; i++) {
      try {
        return await importFn();
      } catch (error) {
        if (i === retries - 1) {
          throw error;
        }
        
        // Wait before retry (exponential backoff)
        await new Promise(resolve => 
          setTimeout(resolve, 1000 * Math.pow(2, i))
        );
      }
    }
    throw new Error('Failed to load module');
  });
}

// Usage
const Dashboard = lazyWithRetry(() => import('./pages/Dashboard'));

🚀 Preloading Routes

Hover Preload

import { useState } from 'react';

function Navigation() {
  const [hoveredRoute, setHoveredRoute] = useState<string | null>(null);
  
  const prefetchRoute = (route: string) => {
    // Preload the route component
    switch (route) {
      case '/dashboard':
        import('./pages/Dashboard');
        break;
      case '/settings':
        import('./pages/Settings');
        break;
    }
  };
  
  return (
    <nav>
      <Link 
        to="/dashboard"
        onMouseEnter={() => {
          setHoveredRoute('/dashboard');
          prefetchRoute('/dashboard');
        }}
      >
        Dashboard
      </Link>
      
      <Link 
        to="/settings"
        onMouseEnter={() => {
          setHoveredRoute('/settings');
          prefetchRoute('/settings');
        }}
      >
        Settings
      </Link>
    </nav>
  );
}
import { useInView } from 'react-intersection-observer';

function PrefetchLink({ to, children }: { to: string; children: React.ReactNode }) {
  const { ref, inView } = useInView({ triggerOnce: true });
  
  useEffect(() => {
    if (inView) {
      // Preload when link becomes visible
      import(`./pages/${to}`);
    }
  }, [inView, to]);
  
  return (
    <Link ref={ref} to={to}>
      {children}
    </Link>
  );
}

// Usage
<PrefetchLink to="/dashboard">Dashboard</PrefetchLink>

Idle Preload

function App() {
  useEffect(() => {
    // Preload routes during idle time
    if ('requestIdleCallback' in window) {
      requestIdleCallback(() => {
        import('./pages/Dashboard');
        import('./pages/Settings');
      }, { timeout: 2000 });
    } else {
      // Fallback for browsers without requestIdleCallback
      setTimeout(() => {
        import('./pages/Dashboard');
        import('./pages/Settings');
      }, 1000);
    }
  }, []);
  
  return <Routes>...</Routes>;
}

📊 Measuring Impact

// Track chunk load time
const Dashboard = lazy(async () => {
  const start = performance.now();
  
  const module = await import('./pages/Dashboard');
  
  const loadTime = performance.now() - start;
  console.log(`Dashboard loaded in ${loadTime}ms`);
  
  // Send to analytics
  analytics.track('chunk_loaded', {
    chunk: 'dashboard',
    loadTime
  });
  
  return module;
});

🎯 Best Practices

// ❌ BAD: Too granular
const DashboardHome = lazy(() => import('./pages/DashboardHome'));
const DashboardView1 = lazy(() => import('./pages/DashboardView1'));
const DashboardView2 = lazy(() => import('./pages/DashboardView2'));

// ✅ GOOD: Group related pages
const Dashboard = lazy(() => import('./pages/Dashboard'));
// Dashboard internally handles sub-routes

2. Always Provide Fallback

// ❌ BAD: No fallback
<Suspense>
  <Route path="/dashboard" element={<Dashboard />} />
</Suspense>

// ✅ GOOD: Meaningful fallback
<Suspense fallback={<DashboardSkeleton />}>
  <Route path="/dashboard" element={<Dashboard />} />
</Suspense>

3. Monitor Bundle Sizes

# Check route chunk sizes
npm run build
ls -lh dist/assets/*.js

# Target: < 100 KB per route (gzipped)

📚 Key Takeaways

  1. Biggest impact - Route splitting is #1 optimization
  2. Zero config in Next.js - Automatic by default
  3. Always use Suspense - Provide loading states
  4. Preload critical routes - On hover or visible
  5. Name your chunks - Better debugging
  6. < 100 KB per route - Target chunk size
  7. Monitor over time - Bundle size grows

Route-based splitting typically gives you 70-80% bundle size reduction with minimal effort. Start here! 🚀

On this page