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.lazywraps a dynamic import and renders a fallback while loadingSuspenseboundaries 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
fallbackUI to prevent layout shifts while loading - Monitor Core Web Vitals (LCP, INP, CLS) after splitting
- Use
preloadfor 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.