React Code Splitting and Lazy Loading Best Practices (2026)
Code splitting breaks your JavaScript bundle into smaller chunks that load on demand. Without it, users download your entire app upfront — including code for pages they never visit. With code splitting, only the code needed for the current view loads immediately, dramatically improving initial page load and Time to Interactive.
Table of Contents
Why Code Splitting Matters
A typical React SPA bundles everything into one JS file. A 500KB main bundle means:
- 500KB downloaded before any page renders
- ~2–4 seconds parse time on mid-range mobile
- Poor Lighthouse scores (LCP, TTI, TBT)
After route-based code splitting, the initial bundle drops to 50–100KB. Other route bundles load only when navigated to.
React.lazy and Suspense
import { lazy, Suspense } from 'react'
// Dynamic import — Vite/webpack creates a separate chunk for HeavyChart
const HeavyChart = lazy(() => import('./HeavyChart'))
// Always wrap lazy components in Suspense
function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<div className="skeleton h-64" />}>
<HeavyChart data={chartData} />
</Suspense>
</div>
)
}
// Error boundary for failed chunk loads (network error, deploy)
import { ErrorBoundary } from 'react-error-boundary'
function SafeLazyChart() {
return (
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) => (
<div>
<p>Failed to load chart</p>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
)}
>
<Suspense fallback={<Skeleton />}>
<HeavyChart />
</Suspense>
</ErrorBoundary>
)
}
Named Exports
React.lazy requires a default export. For named exports, re-export or use .then():
// Re-export approach
const Modal = lazy(() => import('./Modal').then(m => ({ default: m.Modal })))
// Wrapper module (modal-lazy.ts)
export { Modal as default } from './Modal'
// Then: const Modal = lazy(() => import('./modal-lazy'))
// Multiple components from one chunk — share the same dynamic import
const chartImport = import('./charts')
const BarChart = lazy(() => chartImport.then(m => ({ default: m.BarChart })))
const LineChart = lazy(() => chartImport.then(m => ({ default: m.LineChart })))
Route-Based Splitting
The highest-ROI split point — each route loads its own bundle:
// React Router v6 + lazy routes
import { lazy, Suspense } from 'react'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
const Home = lazy(() => import('./pages/Home'))
const Dashboard = lazy(() => import('./pages/Dashboard'))
const ProductDetail = lazy(() => import('./pages/ProductDetail'))
const AdminPanel = lazy(() => import('./pages/AdminPanel'))
const router = createBrowserRouter([
{
path: '/',
element: (
<Suspense fallback={<PageLoader />}>
<Home />
</Suspense>
),
},
{
path: '/dashboard',
element: (
<Suspense fallback={<PageLoader />}>
<Dashboard />
</Suspense>
),
},
])
// Or wrap the entire Router in one Suspense
function App() {
return (
<Suspense fallback={<PageLoader />}>
<RouterProvider router={router} />
</Suspense>
)
}
React Router v6 also supports lazy() directly on route objects:
const router = createBrowserRouter([
{
path: '/dashboard',
lazy: async () => {
const { Dashboard } = await import('./pages/Dashboard')
return { Component: Dashboard }
},
},
])
Component-Level Splitting
Split heavy components that aren't always visible:
// Heavy third-party libraries — split at usage point
const RichTextEditor = lazy(() => import('./RichTextEditor'))
const PdfViewer = lazy(() => import('./PdfViewer'))
const VideoPlayer = lazy(() => import('./VideoPlayer'))
const MapComponent = lazy(() => import('./MapComponent'))
// Conditional rendering — load only when needed
function PostEditor({ post }) {
const [isEditing, setIsEditing] = useState(false)
return (
<div>
<button onClick={() => setIsEditing(true)}>Edit</button>
{isEditing && (
<Suspense fallback={<EditorSkeleton />}>
<RichTextEditor value={post.body} />
</Suspense>
)}
</div>
)
}
// Below-the-fold components — split at scroll intersection
function HomePage() {
const [showMap, setShowMap] = useState(false)
const mapRef = useRef(null)
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) setShowMap(true)
})
if (mapRef.current) observer.observe(mapRef.current)
return () => observer.disconnect()
}, [])
return (
<div>
<HeroSection />
<div ref={mapRef}>
{showMap && (
<Suspense fallback={<div className="h-96 bg-gray-100" />}>
<MapComponent />
</Suspense>
)}
</div>
</div>
)
}
Prefetching Strategies
// Prefetch on hover — chunk ready before user clicks
function NavLink({ to, children }) {
const [Component] = useState(() => lazy(() => import(`./pages/${to}`)))
function prefetch() {
// Trigger the dynamic import early — browser fetches the chunk
import(`./pages/${to}`)
}
return (
<Link to={to} onMouseEnter={prefetch} onFocus={prefetch}>
{children}
</Link>
)
}
// Prefetch after idle
useEffect(() => {
const id = requestIdleCallback(() => {
import('./pages/Dashboard') // Pre-cache likely next page
import('./pages/Settings')
})
return () => cancelIdleCallback(id)
}, [])
Code Splitting in Next.js
Next.js App Router splits automatically at route segments. Use next/dynamic for component-level splitting:
import dynamic from 'next/dynamic'
// Component-level split — SSR disabled for browser-only components
const RichTextEditor = dynamic(() => import('./RichTextEditor'), {
ssr: false,
loading: () => <EditorSkeleton />,
})
// With named export
const Chart = dynamic(
() => import('./Charts').then(mod => mod.BarChart),
{ ssr: false }
)
// Conditional load — modal only loads when open
function PostPage() {
const [showShare, setShowShare] = useState(false)
const ShareModal = dynamic(() => import('./ShareModal'))
return (
<div>
<button onClick={() => setShowShare(true)}>Share</button>
{showShare && <ShareModal onClose={() => setShowShare(false)} />}
</div>
)
}
Bundle Analysis
# Next.js bundle analyzer
npm install @next/bundle-analyzer
// next.config.ts
import bundleAnalyzer from '@next/bundle-analyzer'
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})
export default withBundleAnalyzer({ /* next config */ })
# Run: ANALYZE=true npm run build
# Opens treemap of your bundles in the browser
# Vite bundle visualizer
npm install -D rollup-plugin-visualizer
# Configured in vite.config.ts — generates dist/stats.html
Anti-Patterns to Avoid
- Splitting too granularly — splitting a 5KB component adds HTTP overhead without meaningful benefit. Target chunks >30KB
- Lazy loading above-the-fold content — Hero sections, navigation and visible-on-load content should never be lazy-loaded (harms LCP)
- Creating lazy components inside render —
const C = lazy(() => import(...))inside a component function re-creates on every render and unmounts the component. Define outside - Missing Suspense boundaries — React throws if a lazy component has no ancestor Suspense boundary
- Forgetting error boundaries — Network errors during chunk loading show a blank crash without an error boundary