Skip to content
SP StackPractices
intermediate By Mathias Paulenko

SPA Performance: Code Splitting and Lazy Loading

Improve single-page application load times by splitting bundles at route and component level, implementing lazy loading with React.lazy and dynamic imports

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

SPA Performance: Code Splitting and Lazy Loading

Reduce initial bundle size in single-page applications by splitting code at the route and component level. This recipe demonstrates React.lazy, dynamic imports, and preload strategies that keep time-to-interactive low without sacrificing user experience.

When to Use This

  • Your SPA bundle exceeds 200KB gzipped and loads slowly on mobile
  • Not all routes are accessed by every user on first visit
  • Heavy components (charts, editors, maps) are only needed on specific pages

Solution

1. Route-Level Code Splitting

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

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

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<LoadingSpinner />}>
        <Routes>
          <Route path="/" element={<Dashboard />} />
          <Route path="/reports" element={<Reports />} />
          <Route path="/analytics" element={<Analytics />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

2. Component-Level Lazy Loading

// components/HeavyChart.tsx
import { lazy, Suspense, useState } from 'react';

const Chart = lazy(() => import('./ChartLibrary'));

function Dashboard() {
  const [showChart, setShowChart] = useState(false);

  return (
    <div>
      <button onClick={() => setShowChart(true)}>Show Analytics</button>
      {showChart && (
        <Suspense fallback={<ChartSkeleton />}>
          <Chart data={getData()} />
        </Suspense>
      )}
    </div>
  );
}

3. Prefetch on Hover

// utils/prefetch.ts
const lazyPages = {
  '/reports': () => import('./pages/Reports'),
  '/analytics': () => import('./pages/Analytics'),
};

export function prefetchRoute(path: string): void {
  const loader = lazyPages[path as keyof typeof lazyPages];
  if (loader) loader();
}

// Navigation.tsx
import { prefetchRoute } from './utils/prefetch';

function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
  return (
    <a
      href={to}
      onMouseEnter={() => prefetchRoute(to)}
    >
      {children}
    </a>
  );
}

4. Vite Configuration for Chunking

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['react', 'react-dom', 'react-router-dom'],
          charts: ['recharts'],
          forms: ['react-hook-form', 'zod'],
        },
      },
    },
  },
});

How It Works

  • React.lazy wraps a dynamic import and renders a fallback while loading
  • Suspense boundaries catch loading states and show fallback UI
  • Prefetching on hover starts loading before the user clicks
  • Manual chunks group shared vendor code into cacheable bundles

Variation: Intersection Observer for Below-Fold Content

// hooks/useLazyLoad.ts
import { useEffect, useRef, useState } from 'react';

function useLazyLoad() {
  const ref = useRef<HTMLDivElement>(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setIsVisible(true);
        observer.disconnect();
      }
    });
    if (ref.current) observer.observe(ref.current);
    return () => observer.disconnect();
  }, []);

  return { ref, isVisible };
}

Production Considerations

  • Set proper fallback UI to prevent layout shifts while loading
  • Monitor Core Web Vitals (LCP, INP, CLS) after splitting
  • Use preload for critical routes accessed by most users

Common Mistakes

  • Wrapping every component in lazy, causing excessive network requests
  • Not handling load errors with an ErrorBoundary
  • Forgetting that lazy-loaded routes still need their data fetched

FAQ

Q: Does this work with SSR? A: Yes, but use @loadable/component instead of React.lazy for server-side rendering support.

Q: How small should each chunk be? A: Aim for 30-100KB gzipped per route chunk. Too many tiny chunks hurt performance due to request overhead.