Oversized JavaScript bundles drag down application performance. When the browser must parse and execute too much code upfront, users face longer wait times before they can interact with the page. Search engines penalize slow sites with lower rankings.
Lazy loading addresses this by breaking your code into smaller pieces and fetching them only when required.
This guide covers lazy loading techniques in React and Next.js. You'll learn how to apply React.lazy, next/dynamic, and Suspense, with practical examples ready to adapt for your projects.
Table of Contents
- What is Lazy Loading?
- Prerequisites
- How to Use React.lazy for Code Splitting
- How to Use Suspense with React.lazy
- How to Handle Errors with Error Boundaries
- How to Use next/dynamic in Next.js
- React.lazy vs next/dynamic: When to Use Each
- Real-World Examples
- Conclusion
What is Lazy Loading?
Lazy loading defers code execution until the moment it's actually needed. Rather than shipping your entire application in one bundle, you split it into discrete chunks. The browser fetches each chunk only when a user navigates to a specific route or triggers a particular feature.
Key advantages:
- Faster initial load: A smaller entry bundle reduces time to interactive
- Better Core Web Vitals: Enhances metrics like Largest Contentful Paint and Total Blocking Time
- Reduced bandwidth consumption: Users download only the code they actually use
React implements this through dynamic imports paired with React.lazy(), while Next.js offers next/dynamic for additional server-side rendering capabilities.
Prerequisites
To follow this guide, you'll need:
- Working knowledge of React fundamentals (components, hooks, state management)
- Node.js version 18 or newer
- A React project (Create React App or Vite) or Next.js application for the framework-specific examples
For React examples, any modern setup works. For Next.js sections, use the App Router (Next.js 13+).
How to Use React.lazy for Code Splitting
React.lazy() transforms a component into a dynamically imported module. React fetches that component only when it first renders.
The function accepts a callback that returns a dynamic import() statement. The target module must export the component as default.
Basic implementation:
import { lazy } from 'react';
const HeavyChart = lazy(() => import('./HeavyChart'));
const AdminDashboard = lazy(() => import('./AdminDashboard'));
function App() {
return (
<div>
<h1>My App</h1>
<HeavyChart />
<AdminDashboard />
</div>
);
}
For named exports, map them to a default export:
const ComponentWithNamedExport = lazy(() =>
import('./MyComponent').then((module) => ({
default: module.NamedComponent,
}))
);
You can label chunks for easier identification in browser dev tools:
const HeavyChart = lazy(() =>
import(/* webpackChunkName: "heavy-chart" */ './HeavyChart')
);
Using React.lazy() alone isn't sufficient. Wrap lazy components in Suspense to define what displays during the loading phase.
How to Use Suspense with React.lazy
Suspense displays fallback UI while its children load. It integrates with React.lazy() to manage loading states for dynamically imported components.
Wrap lazy components in Suspense and specify a fallback:
import { lazy, Suspense } from 'react';
const HeavyChart = lazy(() => import('./HeavyChart'));
const AdminDashboard = lazy(() => import('./AdminDashboard'));
function App() {
return (
<div>
<h1>My App</h1>
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart />
</Suspense>
<Suspense fallback={<div>Loading dashboard...</div>}>
<AdminDashboard />
</Suspense>
</div>
);
}
A single Suspense boundary can wrap multiple lazy components:
<Suspense fallback={<div>Loading...</div>}>
<HeavyChart />
<AdminDashboard />
</Suspense>
Custom fallback components improve the user experience:
function LoadingSpinner() {
return (
<div className="loading-container">
<div className="spinner" />
<p>Loading...</p>
</div>
);
}
<Suspense fallback={<LoadingSpinner />}>
<HeavyChart />
</Suspense>
How to Handle Errors with Error Boundaries
React.lazy() and Suspense don't catch loading failures like network errors or missing chunks. Error Boundaries handle these scenarios.
Error Boundaries are class components that implement componentDidCatch or static getDerivedStateFromError to intercept errors in their child tree and render fallback UI.
Simple Error Boundary implementation:
import { Component } from 'react';
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback || <div>Something went wrong.</div>;
}
return this.props.children;
}
}
Combine Error Boundary with Suspense:
import { lazy, Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';
const HeavyChart = lazy(() => import('./HeavyChart'));
function App() {
return (
<ErrorBoundary fallback={<div>Failed to load chart. Please try again.</div>}>
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart />
</Suspense>
</ErrorBoundary>
);
}
When chunk loading fails, the Error Boundary catches it and displays your fallback instead of leaving users with a broken interface.
How to Use next/dynamic in Next.js
Next.js provides next/dynamic, which builds on React.lazy() and Suspense with additional options for server-side rendering.
Basic usage:
'use client';
import dynamic from 'next/dynamic';
const ComponentA = dynamic(() => import('../components/A'));
const ComponentB = dynamic(() => import('../components/B'));
export default function Page() {
return (
<div>
<ComponentA />
<ComponentB />
</div>
);
}
Custom Loading UI
The loading option specifies a placeholder during component load:
const HeavyChart = dynamic(() => import('../components/HeavyChart'), {
loading: () => <p>Loading chart...</p>,
});
Disable Server-Side Rendering
Components that depend on browser-only APIs (like window) need ssr: false:
const ClientOnlyMap = dynamic(() => import('../components/Map'), {
ssr: false,
loading: () => <p>Loading map...</p>,
});
Note: ssr: false only works in Client Components marked with 'use client'.
Load on Demand
Conditionally load components based on user interaction:
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
const Modal = dynamic(() => import('../components/Modal'), {
loading: () => <p>Opening modal...</p>,
});
export default function Page() {
const [showModal, setShowModal] = useState(false);
return (
<div>
<button onClick={() => setShowModal(true)}>Open Modal</button>
{showModal && <Modal onClose={() => setShowModal(false)} />}
</div>
);
}
Named Exports
Extract named exports from the import:
const Hello = dynamic(() =>
import('../components/hello').then((mod) => mod.Hello)
);
Using Suspense with next/dynamic
In React 18+, enable suspense: true to delegate to a parent Suspense boundary:
const HeavyChart = dynamic(() => import('../components/HeavyChart'), {
suspense: true,
});
// In your component:
<Suspense fallback={<div>Loading...</div>}>
<HeavyChart />
</Suspense>
Important: suspense: true is incompatible with ssr: false and the loading option. Use the Suspense fallback instead.
React.lazy vs next/dynamic: When to Use Each
| Feature | React.lazy + Suspense | next/dynamic |
|---|---|---|
| Framework | Any React app (Create React App, Vite, etc.) | Next.js only |
| Server-Side Rendering | Not supported | Supported by default |
| Disable SSR | N/A | ssr: false option |
| Loading UI | Suspense fallback prop |
Built-in loading option |
| Error handling | Requires Error Boundary | Requires Error Boundary |
| Named exports | Manual .then() mapping |
Same .then() pattern |
| Suspense mode | Always uses Suspense | Optional via suspense: true |
When to Use React.lazy
- You're building a standard React application without Next.js
- Your project uses Create React App, Vite, or custom Webpack configuration
You don't need Server-Side Rendering
You want a simple, framework-agnostic approach
When to Use next/dynamic
You're building a Next.js app
You need SSR for some components but want to disable it selectively for others
You want built-in loading placeholders without manually wiring up
SuspenseYou want Next.js-specific optimizations and sensible defaults out of the box
Real-World Examples
Example 1: Route-Based Code Splitting in React
Split your app by route so each page's code is fetched only when the user navigates to it:
// App.jsx
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import ErrorBoundary from './ErrorBoundary';
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<BrowserRouter>
<ErrorBoundary fallback={<div>Failed to load page.</div>}>
<Suspense fallback={<div>Loading page...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</ErrorBoundary>
</BrowserRouter>
);
}
Example 2: Lazy Loading a Heavy Chart Library in Next.js
Defer loading a chart library until the user explicitly opens the analytics section — keeping the initial bundle lean:
// app/analytics/page.jsx
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('../components/Chart'), {
ssr: false,
loading: () => (
<div className="chart-skeleton">
<div className="skeleton-bar" />
<div className="skeleton-bar" />
<div className="skeleton-bar" />
</div>
),
});
export default function AnalyticsPage() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<h1>Analytics</h1>
<button onClick={() => setShowChart(true)}>Load Chart</button>
{showChart && <Chart />}
</div>
);
}
Example 3: Lazy Loading a Modal
Load a modal component only when the user triggers it — there's no reason to ship that code upfront:
// React (with React.lazy)
import { lazy, Suspense, useState } from 'react';
const Modal = lazy(() => import('./Modal'));
function ProductPage() {
const [showModal, setShowModal] = useState(false);
return (
<div>
<button onClick={() => setShowModal(true)}>Add to Cart</button>
{showModal && (
<Suspense fallback={null}>
<Modal onClose={() => setShowModal(false)} />
</Suspense>
)}
</div>
);
}
// Next.js (with next/dynamic)
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
const Modal = dynamic(() => import('./Modal'), {
loading: () => null,
});
export default function ProductPage() {
const [showModal, setShowModal] = useState(false);
return (
<div>
<button onClick={() => setShowModal(true)}>Add to Cart</button>
{showModal && <Modal onClose={() => setShowModal(false)} />}
</div>
);
}
Example 4: Lazy Loading External Libraries
Import a third-party library on demand — in this case, loading fuse.js only when the user begins a search:
'use client';
import { useState } from 'react';
const names = ['Alice', 'Bob', 'Charlie', 'Diana'];
export default function SearchPage() {
const [results, setResults] = useState([]);
const [query, setQuery] = useState('');
const handleSearch = async (value) => {
setQuery(value);
if (!value) {
setResults([]);
return;
}
// Load fuse.js only when the user actually searches
const Fuse = (await import('fuse.js')).default;
const fuse = new Fuse(names);
setResults(fuse.search(value));
};
return (
<div>
<input
type="text"
placeholder="Search..."
value={query}
onChange={(e) => handleSearch(e.target.value)}
/>
<ul>
{results.map((result) => (
<li key={result.refIndex}>{result.item}</li>
))}
</ul>
</div>
);
}
Conclusion
Lazy loading is one of the most practical tools in a frontend developer's performance toolkit. It shrinks your initial bundle by deferring code until it's actually needed. Here's a quick recap of the key primitives:
React.lazy() — The standard choice for plain React apps. Requires a default export and pairs with dynamic
import().Suspense — Wrap lazy components in
Suspenseand supply afallbackto handle the loading state gracefully.Error Boundaries — Catch chunk load failures and surface a user-friendly error UI instead of a blank screen.
next/dynamic — The Next.js equivalent, with the added benefit of SSR control and built-in loading state support.
The decision is straightforward: use React.lazy for React-only projects and next/dynamic for Next.js. In both cases, pair your lazy components with Suspense and Error Boundaries for a robust, production-ready setup.
A good starting point: identify your heaviest components — charts, modals, admin panels — and lazy load them first. Measure your bundle size and Core Web Vitals before and after to quantify the impact.