~/priyank$
> Initializing portfolio...
> Loading components... โœ“
> Fetching experience... โœ“
> Compiling skills... โœ“
> Ready.
~/priyank $
~/blog/tech
post.metadata
title: "Code Splitting & Lazy Loading in React: The Ultimate Guide"
date: 02 May 2026
readTime: 18 min read
tags: ["React", "Performance", "JavaScript", "Web Vitals"]
author: "Priyank Deep Singh"

Code Splitting & Lazy Loading in React: The Ultimate Guide

Learn how to make your React app lightning fast by splitting your code into smaller chunks and loading them only when needed. Explained so simply, even a kid could get it.

Priyank Deep Singh
Priyank Deep Singh
18 min read ยท 02 May 2026
Code Splitting & Lazy Loading in React: The Ultimate Guide

Imagine you ordered a pizza. But instead of delivering just the pizza, the delivery guy brings the entire restaurant to your doorstep. The tables, chairs, the kitchen, the other customers' orders, everything. Sounds ridiculous, right?

Well, that's exactly what many React apps do. When a user visits your website, the browser downloads ALL the JavaScript for EVERY page, even the pages the user will never visit. That's like shipping the entire restaurant when someone just wanted a Margherita.

Code splitting and lazy loading fix this. They let you deliver only the slice the user needs, right when they need it. Let's break it down piece by piece.

What You'll Learn
01What Is Code Splitting?02What Is Lazy Loading?03React.lazy() & Suspense04Route-Based Code Splitting05Component-Level Lazy Loading06Named Exports & Advanced Patterns07Error Boundaries (When Things Go Wrong)08Preloading & Prefetching09Real-World Performance Gains10Common Mistakes to Avoid

01What Is Code Splitting?

When you build a React app, tools like Webpack, Vite, or Turbopack take all your JavaScript files and bundle them into one (or a few) big files. This bundled file is what the browser downloads.

๐ŸŽฏ Think of it Like This
Think of it like packing for a trip. Without code splitting, you stuff everything you own into one giant suitcase. Winter coats, swimsuits, hiking boots, formal shoes, even though you're going to the beach for 3 days. With code splitting, you pack only what you need for this trip, and leave the rest at home.

Code splitting means breaking that one giant JavaScript bundle into smaller chunks. Each chunk contains only the code needed for a specific part of your app. The browser downloads each chunk only when it's needed.

Before vs After Code Splitting
bundle.js
2.4 MB
Without splitting
โ†’
main.js85 KB
home.js42 KB
dashboard.js120 KB
With splitting: load only what's needed

How does the browser benefit?

Smaller bundles = faster download = faster paint = happier users. The browser parses and executes less JavaScript upfront, which directly improves your Largest Contentful Paint (LCP), Interaction to Next Paint (INP), and Cumulative Layout Shift (CLS). These are the Core Web Vitals that Google uses to rank your website.

02What Is Lazy Loading?

Lazy loading is the technique of loading something only when it's actually needed. It goes hand-in-hand with code splitting.

๐ŸŽฏ Think of it Like This
Imagine a book store. Eager loading is like the store owner reading every single book before opening the shop, just in case a customer asks about one. Lazy loading is like keeping the books on the shelf and only picking one up when a customer asks for it. Much smarter, right?

In React, lazy loading means: "Don't download this component's code until the user actually needs to see it." This could be when they navigate to a page, click a button, scroll to a section, or open a modal.

The two concepts work together like peanut butter and jelly:

  • Code Splitting = chopping the bundle into pieces (the what)
  • Lazy Loading = loading those pieces on demand (the when)

03React.lazy() & Suspense

React gives you two built-in tools to make code splitting and lazy loading dead simple: React.lazy() and <Suspense>.

The Old Way (Eager Loading)

Here's how you normally import a component. This is called a static import:

App.tsxtsx
import HeavyDashboard from './components/HeavyDashboard';
import Settings from './components/Settings';
import Analytics from './components/Analytics';
// ALL three components are bundled together
// The browser downloads ALL of them upfront
// Even if the user never visits Settings or Analytics!
function App() {
return (
<div>
<HeavyDashboard />
<Settings />
<Analytics />
</div>
);
}
โš ๏ธ Watch Out
With static imports, every component is included in the main bundle. If Analytics imports a massive charting library (like Chart.js at 200KB), that 200KB gets downloaded even if the user never opens the Analytics page!

The New Way (Lazy Loading)

App.tsxtsx
import React, { Suspense } from 'react';
// Dynamic imports: these are NOT downloaded upfront!
const HeavyDashboard = React.lazy(() => import('./components/HeavyDashboard'));
const Settings = React.lazy(() => import('./components/Settings'));
const Analytics = React.lazy(() => import('./components/Analytics'));
function App() {
return (
<div>
{/* Suspense shows a fallback while the component loads */}
<Suspense fallback={<LoadingSpinner />}>
<HeavyDashboard />
</Suspense>
<Suspense fallback={<p>Loading settings...</p>}>
<Settings />
</Suspense>
<Suspense fallback={<SkeletonChart />}>
<Analytics />
</Suspense>
</div>
);
}
function LoadingSpinner() {
return (
<div className="flex items-center justify-center p-8">
<div className="animate-spin h-8 w-8 border-2
border-cyan-400 border-t-transparent
rounded-full" />
</div>
);
}

Let's break down what's happening, step by step:

1
React.lazy() wraps a dynamic import
Instead of importing the component at the top of the file, we tell React: "Hey, don't load this yet. I'll tell you when."
2
Dynamic import() returns a Promise
When the component IS needed, the browser fetches that specific chunk of JavaScript over the network. It's like ordering food delivery: you only order when you're hungry.
3
<Suspense> catches the loading state
While the chunk is downloading, React doesn't know what to render yet. Suspense says: "Show this fallback (spinner, skeleton, etc.) until the real component arrives."
4
Component renders normally
Once downloaded, the lazy component renders exactly like any other component. No difference at all. The user sees the real UI.

04Route-Based Code Splitting

The most common and impactful place to use code splitting is at the route level. Each page of your app becomes its own chunk. When the user navigates to /dashboard, only the dashboard code loads. When they go to /settings, only the settings code loads.

โšก Pro Tip
Route-based splitting is the lowest-hanging fruit for performance. It gives you the biggest bang for your buck because pages are natural split points. Users only visit one page at a time.

With React Router

App.tsxtsx
import React, { Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// Each page is lazy loaded, separate chunk per route
const Home = React.lazy(() => import('./pages/Home'));
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));
const Profile = React.lazy(() => import('./pages/Profile'));
const Analytics = React.lazy(() => import('./pages/Analytics'));
// A nice full-page loading skeleton
function PageLoader() {
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '60vh',
flexDirection: 'column',
gap: '1rem'
}}>
<div className="animate-spin h-10 w-10 border-4
border-cyan-400 border-t-transparent
rounded-full" />
<p style={{ color: '#8b949e', fontSize: '0.875rem' }}>
Loading page...
</p>
</div>
);
}
function App() {
return (
<BrowserRouter>
{/* One Suspense wrapper for all routes */}
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/profile" element={<Profile />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}

With Next.js (It's Automatic!)

If you're using Next.js (like this very portfolio), you get route-based code splitting for free. Each route segment (page.tsx, layout.tsx, loading.tsx) inside the app/ directory automatically becomes its own chunk. Components you import into a page are bundled with that route unless you separately use next/dynamic.

File Structureplaintext
app/
โ”œโ”€โ”€ page.tsx โ†’ chunk: main.js (loaded on /)
โ”œโ”€โ”€ dashboard/
โ”‚ โ””โ”€โ”€ page.tsx โ†’ chunk: dashboard.js (loaded on /dashboard)
โ”œโ”€โ”€ settings/
โ”‚ โ””โ”€โ”€ page.tsx โ†’ chunk: settings.js (loaded on /settings)
โ””โ”€โ”€ analytics/
โ””โ”€โ”€ page.tsx โ†’ chunk: analytics.js (loaded on /analytics)
// Next.js automatically code-splits each route!
// No React.lazy() or Suspense needed for routes.
๐Ÿ’ก Good to Know
In Next.js App Router, you can use loading.tsx files to create loading UI for each route. It's like <Suspense> but built into the framework.

05Component-Level Lazy Loading

Route-level splitting is great, but sometimes you need to go deeper. Heavy components within a page (modals, charts, rich text editors, maps) can be lazy loaded too.

Lazy Loading a Modal

ProductPage.tsxtsx
import React, { Suspense, useState } from 'react';
// The heavy modal (with image gallery, reviews, etc.)
// is only loaded when the user clicks "Quick View"
const ProductModal = React.lazy(
() => import('./components/ProductModal')
);
function ProductPage() {
const [showModal, setShowModal] = useState(false);
return (
<div>
<h1>Our Products</h1>
<ProductGrid />
<button onClick={() => setShowModal(true)}>
Quick View
</button>
{/* Modal code is NOT downloaded until clicked */}
{showModal && (
<Suspense fallback={<ModalSkeleton />}>
<ProductModal onClose={() => setShowModal(false)} />
</Suspense>
)}
</div>
);
}
function ModalSkeleton() {
return (
<div style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
width: 500, height: 400,
borderRadius: '1rem',
background: '#1a1a2e',
animation: 'pulse 1.5s infinite'
}} />
</div>
);
}

Lazy Loading a Chart Library

AnalyticsDashboard.tsxtsx
import React, { Suspense } from 'react';
// Chart.js + react-chartjs-2 is ~200KB
// Only loaded when user visits the analytics tab
const SalesChart = React.lazy(
() => import('./charts/SalesChart')
);
const RevenueChart = React.lazy(
() => import('./charts/RevenueChart')
);
function AnalyticsDashboard() {
const [activeTab, setActiveTab] = useState('overview');
return (
<div>
<TabBar active={activeTab} onChange={setActiveTab} />
{activeTab === 'overview' && <OverviewCards />}
{activeTab === 'sales' && (
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
)}
{activeTab === 'revenue' && (
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
)}
</div>
);
}
function ChartSkeleton() {
return (
<div style={{
height: 300, borderRadius: '1rem',
background: 'linear-gradient(90deg, #1a1a2e 25%, #2a2a4e 50%, #1a1a2e 75%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite',
}} />
);
}
๐ŸŽฏ Think of it Like This
Think of component-level lazy loading like a restaurant menu. The restaurant doesn't cook every dish when you sit down. It waits for you to order. The appetizer comes first, then the main course, then dessert. Each "course" is loaded when it's your turn to eat it.

06Named Exports & Advanced Patterns

React.lazy() only supports default exports out of the box. But what if your component uses a named export? Here's the workaround:

๐Ÿ’ก Good to Know
Note: This limitation is specific to React.lazy(). If you're using Next.js, next/dynamic handles named exports natively. And React 19's Server Components have their own lazy-loading story that bypasses this entirely.
lazyNamedExport.tsxtsx
// โŒ This WON'T work โ€” React.lazy needs a default export
const MyChart = React.lazy(() => import('./Charts'));
// Error: Charts doesn't have a default export!
// โœ… Fix 1: wrap the named export as default in .then()
const LazyChart = React.lazy(() =>
import('./Charts').then(module => ({
default: module.MyChart // Wrap named export as default
}))
);
// โœ… Fix 2: create a barrel file that re-exports as default
// Charts/index.ts
export { MyChart as default } from './MyChart';
// Now you can lazy import normally
const LazyChart2 = React.lazy(() => import('./Charts'));

Creating a Reusable Lazy Loader

If you're lazy loading many components, create a helper function to keep things DRY:

utils/lazyImport.tstsx
import React from 'react';
/**
* Helper to lazy-load named exports
* Usage: const { MyComponent } = lazyImport(() => import('./file'), 'MyComponent')
*/
export function lazyImport<
T extends React.ComponentType<any>,
K extends string
>(factory: () => Promise<Record<K, T>>, name: K) {
return {
[name]: React.lazy(() =>
factory().then(module => ({
default: module[name]
}))
)
} as Record<K, React.LazyExoticComponent<T>>;
}
// Usage
const { UserProfile } = lazyImport(
() => import('./components/Users'),
'UserProfile'
);
const { SettingsPanel } = lazyImport(
() => import('./components/Settings'),
'SettingsPanel'
);

07Error Boundaries (When Things Go Wrong)

What happens if the network fails while downloading a lazy chunk? The user sees a white screen. Not great. Error Boundaries catch these failures and show a friendly error message instead.

ErrorBoundary.tsxtsx
import React, { Component } from 'react';
interface Props {
children: React.ReactNode;
fallback?: React.ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div style={{
padding: '2rem',
textAlign: 'center',
borderRadius: '1rem',
background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.2)'
}}>
<h3>Something went wrong</h3>
<p>Failed to load this section.</p>
<button onClick={() => window.location.reload()}>
Try Again
</button>
</div>
);
}
return this.props.children;
}
}

Using Error Boundary with Suspense

App.tsxtsx
import React, { Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
function App() {
return (
// Error Boundary catches chunk-load failures
<ErrorBoundary fallback={<ChunkErrorFallback />}>
{/* Suspense handles the loading state */}
<Suspense fallback={<LoadingSpinner />}>
<Dashboard />
</Suspense>
</ErrorBoundary>
);
}
function ChunkErrorFallback() {
return (
<div style={{ textAlign: 'center', padding: '4rem' }}>
<h2>Oops! Failed to load</h2>
<p>Check your internet connection and try again.</p>
<button
onClick={() => window.location.reload()}
style={{
padding: '0.75rem 2rem',
borderRadius: '9999px',
background: 'linear-gradient(135deg, #22d3ee, #818cf8)',
border: 'none',
color: '#0a0e14',
fontWeight: 700,
cursor: 'pointer'
}}
>
Reload Page
</button>
</div>
);
}
โš ๏ธ Watch Out
Always wrap lazy components with both <ErrorBoundary> and <Suspense>. Suspense handles the happy path (loading), Error Boundary handles the sad path (failure). Together, they cover all scenarios.

08Preloading & Prefetching

Lazy loading is great, but there's a catch: the user has to wait for the chunk to download when they first need it. What if we could start downloading before they click?

๐ŸŽฏ Think of it Like This
It's like a waiter at a fancy restaurant. A good waiter doesn't wait for you to ask for water. They see your glass is almost empty and refill it before you notice. Preloading works the same way: you predict what the user will need next and start loading it in the background.

Preload on Hover

Navigation.tsxtsx
// Store the lazy component reference
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
// Preload function: call import() again to trigger download
function preloadDashboard() {
import('./pages/Dashboard');
// Calling import() twice won't download twice โ€” the module
// system returns the same Promise, and the browser HTTP cache
// prevents duplicate network requests.
}
function Navigation() {
return (
<nav>
<Link to="/">Home</Link>
{/* Start downloading when user HOVERS over the link */}
<Link
to="/dashboard"
onMouseEnter={preloadDashboard} // Preload!
onFocus={preloadDashboard} // Accessibility
>
Dashboard
</Link>
</nav>
);
}

Preload After Initial Render

App.tsxtsx
import { useEffect } from 'react';
// Lazy components
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));
function App() {
useEffect(() => {
// After the main page loads and the user is idle,
// start preloading the most likely next pages
const timer = setTimeout(() => {
import('./pages/Dashboard');
import('./pages/Settings');
}, 3000); // Wait 3 seconds after mount
return () => clearTimeout(timer);
}, []);
return (
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
โšก Pro Tip
The golden rule of preloading: preload what the user is likely to need next, not everything. If 80% of users go to the Dashboard after the Home page, preload Dashboard. Don't preload the Admin panel that only 2% of users visit.

09Real-World Performance Gains

Let's look at typical numbers. These are illustrative of what code splitting can do for a medium-sized React app (your mileage will vary based on app size and architecture):

Performance Impact: Real Numbers
Initial Bundle Size
2.4 MBโ†’340 KB
86% smaller
Largest Contentful Paint
4.5sโ†’1.8s
60% faster
First Contentful Paint
3.8sโ†’1.2s
68% faster
Lighthouse Score
42โ†’94
+52 points

How to Measure Your Bundle

Terminalbash
# Analyze your bundle with webpack-bundle-analyzer
npm install --save-dev webpack-bundle-analyzer
# For Next.js, use @next/bundle-analyzer
npm install @next/bundle-analyzer
# For Vite, use rollup-plugin-visualizer
npm install --save-dev rollup-plugin-visualizer
# Then run your build and inspect the output:
npm run build
# Look at the chunk sizes in the build output!
next.config.tstsx
// Next.js bundle analyzer setup
import withBundleAnalyzer from '@next/bundle-analyzer';
const config = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})({
// your Next.js config
});
export default config;
// Run with: ANALYZE=true npm run build

10Common Mistakes to Avoid

Code splitting is powerful, but it's easy to mess up. Here are the traps I've seen developers fall into (and how to dodge them):

โœ—Lazy loading everything

If you lazy load a tiny 2KB component, the overhead of creating a separate network request is more expensive than just including it in the main bundle.

โœ“Only lazy load components that are 30KB+ or that import heavy dependencies.
โœ—Forgetting Suspense boundaries

Without Suspense, React throws an error when a lazy component hasn't loaded yet. Your entire app crashes instead of showing a loading state.

โœ“Always wrap lazy components with <Suspense>. Add an <ErrorBoundary> too.
โœ—Splitting at the wrong level

Splitting a Header component that's on every page means an extra request on every navigation. That's slower, not faster.

โœ“Split at natural boundaries: routes, modals, tabs, below-the-fold content.
โœ—Not measuring before and after

You might think you're improving performance, but without data, you could be making it worse (death by a thousand chunks).

โœ“Use Lighthouse, bundle analyzer, and real user monitoring (RUM) to validate improvements.
โœ—Ignoring loading states

A blank screen while a chunk loads feels broken. Users don't know something is happening.

โœ“Design beautiful skeleton screens and loading animations. Make waiting feel intentional, not broken.

Wrapping Up

Code splitting and lazy loading aren't just "nice to have." They're essential for any React app that cares about user experience and SEO. Here's your action plan:

  1. Start with route-based splitting (biggest impact, lowest effort)
  2. Lazy load heavy components: modals, charts, editors, maps
  3. Add Suspense + Error Boundaries everywhere you lazy load
  4. Preload on hover for critical navigation paths
  5. Measure with Lighthouse & bundle analyzer. Trust data, not gut feeling
  6. Don't over-split. Tiny components don't need their own chunk

Remember the pizza delivery analogy? Stop shipping the entire restaurant. Ship just the pizza. Your users (and their slow 3G connections) will thank you.

share.sh
$ echo "Share this article"
Priyank Deep Singh
Priyank Deep Singh

Frontend engineer who loves building fast, accessible, and beautiful web experiences. Writing about React, Next.js, and everything in between.