React Lazy Loading and Code Splitting: A Practical Tutorial
Learn React lazy loading and code splitting with Suspense, routes, and bundlers. Includes patterns, pitfalls, and performance tips.
Image used for representation purposes only.
Why lazy loading and code splitting matter
Modern React apps can ship hundreds of kilobytes (or megabytes) of JavaScript. That delays interactivity, hurts Core Web Vitals, and wastes bandwidth. Code splitting breaks your bundle into smaller chunks that load on demand. Lazy loading is the React-friendly way to defer rendering of code-split modules until they’re needed, while showing a fallback UI. Together they:
- Reduce initial download and parse/execute time
- Improve Time to Interactive (TTI) and Interaction to Next Paint (INP)
- Let you prioritize above-the-fold features and defer the rest
This tutorial walks through practical patterns for React.lazy, route-level splitting, prefetching, error handling, measurement, and bundler configuration with Vite/Rollup and webpack.
How lazy loading works in React
- JavaScript bundlers split code when they see dynamic imports:
import('…'). - React exposes
React.lazy(() => import('…'))to turn those dynamically imported modules into components. - You wrap lazy components in
<Suspense>and provide afallbackUI (spinner, skeleton, shimmer). React renders the fallback while the chunk is being fetched and evaluated, then swaps in the real component.
Key rules:
React.lazyexpects the module to have a default export. If you only have named exports, add a small wrapper.- Suspense boundaries can be nested; place them near components that may be delayed to avoid blanking out large page regions.
Project setup (Vite + React)
You can use any toolchain; Vite is a fast default.
# create a new project
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev
We’ll add a few components to demonstrate splitting.
// src/pages/Dashboard.tsx
export default function Dashboard() {
return <h1>Dashboard</h1>;
}
// src/pages/Reports.tsx (heavier screen)
export default function Reports() {
return <h1>Reports</h1>;
}
// src/widgets/Chart.tsx (heavy third-party lib)
import { useEffect } from 'react';
export default function Chart() {
useEffect(() => {
// imagine this imports a big charting lib on mount
}, []);
return <div>Chart goes here</div>;
}
Component-level splitting with React.lazy
Use React.lazy for components that aren’t needed immediately (dialogs, rich editors, charts, admin panels).
// src/App.tsx
import { Suspense, lazy } from 'react';
import Dashboard from './pages/Dashboard';
const Reports = lazy(() => import('./pages/Reports'));
const Chart = lazy(() => import('./widgets/Chart'));
export default function App() {
return (
<main>
<Dashboard />
<Suspense fallback={<div className='skeleton'>Loading reports…</div>}>
<Reports />
<Chart />
</Suspense>
</main>
);
}
If the module only exposes a named export, map it to default:
// src/lazyNamed.tsx
import { lazy } from 'react';
export const LazySettings = lazy(() =>
import('./pages/Settings').then(mod => ({ default: mod.Settings }))
);
Route-level code splitting with React Router
Route modules are perfect for splitting because users don’t need every page on first load.
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { createBrowserRouter, RouterProvider, defer, lazyRouteModule } from 'react-router-dom';
import { Suspense, lazy } from 'react';
const Root = lazy(() => import('./routes/Root'));
const Reports = lazy(() => import('./routes/Reports')); // page-level chunk
const Admin = lazy(() => import('./routes/Admin'));
const router = createBrowserRouter([
{
path: '/',
element: (
<Suspense fallback={<div>Loading app…</div>}>
<Root />
</Suspense>
),
children: [
{
index: true,
lazy: () => lazyRouteModule(() => import('./routes/Home')),
},
{
path: 'reports',
element: (
<Suspense fallback={<div>Loading reports…</div>}>
<Reports />
</Suspense>
),
},
{
path: 'admin',
element: (
<Suspense fallback={<div>Loading admin…</div>}>
<Admin />
</Suspense>
),
},
],
},
]);
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);
Notes:
- Wrap each lazy route with its own Suspense to keep the rest of the UI responsive.
- For data loading routes, you can show route-specific skeletons inside the same Suspense boundary.
Designing effective Suspense boundaries
- Place small, targeted boundaries near widgets that may be slow (e.g., a chart panel) instead of one giant boundary around the entire page.
- Prefer skeletons that mimic final layout for better perceived performance.
- Use nested boundaries so critical UI (navbar, shell, search) never disappears.
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
<main className='grid'>
<Sidebar />
<Suspense fallback={<CardSkeleton />}>
<Chart />
</Suspense>
</main>
Handling errors from dynamic imports
Chunk loads can fail (offline, cache corruption, deploy races). Combine Suspense with an error boundary.
// src/ErrorBoundary.tsx
import { Component, ReactNode } from 'react';
type Props = { children: ReactNode; fallback?: ReactNode };
type State = { hasError: boolean };
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
render() {
if (this.state.hasError) {
return this.props.fallback ?? <div>Something went wrong.</div>;
}
return this.props.children;
}
}
// usage
<ErrorBoundary fallback={<div>Failed to load module. Please retry.</div>}>
<Suspense fallback={<Spinner />}>
<HeavyWidget />
</Suspense>
</ErrorBoundary>
You can also add a retry button that calls window.location.reload() or toggles a key to reattempt the import.
Prefetching and preloading chunks (without hurting TTI)
Lazy doesn’t mean never. For routes and components the user is likely to visit next, prefetch during idle time.
Strategies:
- Hover/tap intent prefetch: start import when the user hovers a link.
- Viewport heuristics: prefetch for links visible above the fold.
- Post-hydration idle prefetch: queue likely targets after the main thread is free.
// intent-based prefetch for a route component
import { startTransition } from 'react';
const Reports = lazy(() => import('./routes/Reports'));
function ReportsLink() {
const onPointerEnter = () => {
// kick off the network request early
startTransition(() => {
import('./routes/Reports');
});
};
return <a href='/reports' onPointerEnter={onPointerEnter}>Reports</a>;
}
HTML prefetch hints can help too:
<!-- in index.html or a head manager -->
<link rel='prefetch' href='/assets/Reports.[hash].js' as='script'>
Note: use prefetch sparingly. Over-prefetching increases data costs and may compete with critical resources on slow networks.
Naming and grouping chunks
Bundlers decide chunk boundaries, but you can guide them.
- webpack: magic comments
const Admin = lazy(() => import(/* webpackChunkName: "admin" */ './routes/Admin'));
- Vite/Rollup: manual chunks via build options
// 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'],
charts: ['echarts', 'chart.js'],
},
},
},
},
});
Tips:
- Keep the entry chunk small (<100–150 kB gzip ideal).
- Group rarely used heavy libs (e.g., rich editors, charting) into their own chunks so they don’t bloat the entry or frequently visited routes.
CSS, images, and other assets
- With CSS Modules or plain CSS imports, bundlers can also split CSS per-chunk. Ensure your skeletons include critical styles in the initial CSS to avoid FOUC.
- Lazy-loaded components can import images; Vite and webpack will create hashed asset files and load them when the chunk executes.
Server rendering and streaming notes
If you use a framework with SSR and streaming (Next.js, Remix, others), Suspense boundaries can stream HTML progressively while deferring the JS for lazy components. In custom SSR setups, ensure your server uses React’s streaming APIs and that you serve the chunk map so the client can hydrate lazy boundaries correctly. When in doubt, lean on a framework that wires this up for you.
Testing lazy components
Testing libraries can await the hydrated UI. Use findBy* queries, which wait for the lazy component to appear.
// Example with @testing-library/react
import { render, screen } from '@testing-library/react';
import { Suspense } from 'react';
import App from './App';
it('renders Reports after load', async () => {
render(
<Suspense fallback={<div>loading…</div>}>
<App />
</Suspense>
);
expect(await screen.findByText(/Reports/i)).toBeInTheDocument();
});
Real-world patterns that pay off
- Split modal-heavy features: import the modal when the user clicks the button, not on page load.
- Defer WYSIWYG editors: load only when the user enters edit mode.
- Analytics and A/B SDKs: lazy load after first interaction or during idle.
- Charts and maps: render lightweight placeholders first; load the heavy visualization on demand.
Common pitfalls (and fixes)
- Over-splitting: too many tiny chunks can increase HTTP overhead and block on multiple round trips. Find a balance.
- Missing boundaries: calling
React.lazywithout a Suspense parent throws at runtime. Add a boundary. - Flash of empty content: large boundaries with generic spinners feel slow. Use skeletons shaped like the final UI.
- Cache busting deploys: users with an older HTML may try to load now-deleted chunks. Keep a short stale-while-revalidate window or serve old chunks for a grace period.
- Global singletons: if you inadvertently create multiple copies of a singleton (e.g., i18n store) across chunks, hoist it to the application entry or a shared vendor chunk.
Measuring the impact
Quantify, don’t guess.
- Bundle analyzers
- Vite:
rollup-plugin-visualizerto view chunk graphs. - webpack:
webpack-bundle-analyzer.
- Vite:
- Browser tooling
- Performance panel: measure TTI/INP and CPU time.
- Network panel: confirm fewer bytes on initial load.
- Coverage: find code that never runs on the first screen.
- Field data
- Lighthouse and WebPageTest for lab signals.
- RUM analytics (e.g., INP, LCP) to verify real-user benefits.
A production-ready checklist
- Keep entry chunk lean; push non-critical UI behind Suspense.
- Add targeted skeletons for each lazy region.
- Wrap lazy zones with ErrorBoundary and display a retry.
- Prefetch likely next screens on hover/idle, but rate-limit.
- Group heavy libs into separate manual chunks.
- Analyze bundles before/after changes.
- Verify behavior on slow 3G and low-memory devices.
Putting it together: a small pattern library
// src/lazy.ts
import { lazy } from 'react';
export const lazyDefault = <T extends React.ComponentType<any>>(factory: () => Promise<{ default: T }>) => lazy(factory);
export const lazyNamed = <T extends React.ComponentType<any>>(factory: () => Promise<any>, key: string) =>
lazy(() => factory().then(mod => ({ default: mod[key] as T })));
// usage
export const LazyEditor = lazyDefault(() => import('./widgets/Editor'));
export const LazySettings = lazyNamed(() => import('./pages/Settings'), 'Settings');
// src/prefetch.ts
import { startTransition } from 'react';
export function prefetch<T>(loader: () => Promise<T>) {
let done = false;
const run = () => {
if (!done) {
done = true;
startTransition(() => void loader());
}
};
return run;
}
// usage
const prefetchReports = prefetch(() => import('./routes/Reports'));
// attach to link hover or intersection observer
// src/AppShell.tsx
import { Suspense } from 'react';
import { ErrorBoundary } from './ErrorBoundary';
import { LazyEditor } from './lazy';
export function AppShell() {
return (
<div className='layout'>
<Header />
<main>
<ErrorBoundary fallback={<div>Could not load editor. <button onClick={() => location.reload()}>Retry</button></div>}>
<Suspense fallback={<EditorSkeleton />}>
<LazyEditor />
</Suspense>
</ErrorBoundary>
</main>
</div>
);
}
Conclusion
Code splitting and lazy loading transform performance by delivering only what users need, when they need it. Start with route-level splitting, wrap risky areas with Suspense and an error boundary, then layer on smart prefetching and chunk grouping. Measure as you go. With a thoughtful boundary strategy and a few lines of code, you can make React apps feel instantly faster without sacrificing capability.
Related Posts
React Hooks Best Practices: Patterns for Clean, Correct, and Performant Components
A practical guide to React Hooks best practices: state, effects, memoization, refs, custom hooks, concurrency, testing, and pitfalls—with examples.
React Server Components Tutorial: Build Faster Apps with Less Client JavaScript
Learn React Server Components with Next.js and React Router: server-first data, client boundaries, caching, mutations, and security in 2026.
React Hydration Mismatch: A Practical Debugging Guide
Learn how to diagnose and fix React hydration mismatches with step-by-step checks, common causes, and production-safe patterns for SSR and Next.js.