Code Splitting and Lazy Loading โ React.lazy, Suspense, and Dynamic Imports
Day 22 of 40 โ React System Design Series

Hi, Iโm Richa โ a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons Iโve learned working on large codebases. My goal is to share practical insights โ not shortcuts โ for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If youโre serious about improving as an engineer, youโre in the right place.
Hey everyone, Richa here! ๐

Day 22. And today we talk about one of the most impactful performance wins available in any React app โ and one of the most underused.
Code splitting.
Here's the thing about JavaScript bundles. When you build a React app, by default everything gets bundled into one (or a few) large JS files. The browser has to download, parse, and execute all of that before the user sees anything interactive.
Your settings page. Your admin dashboard. Your rarely-visited "about" page. All downloaded upfront. Even if the user is only visiting the homepage.
Code splitting lets you break that bundle into smaller chunks โ and load them only when they're needed.
The problem โ one giant bundle
Without code splitting:
main.bundle.js โ 2.4MB
โโโ Login page
โโโ Dashboard (complex, lots of charts)
โโโ Settings
โโโ Admin panel
โโโ Reports page (rarely visited)
โโโ ... everything else
User visits the login page. Browser downloads 2.4MB of JavaScript. Most of it is code for pages they haven't visited yet and might never visit.
With code splitting:
main.bundle.js โ 180KB (shared code, router, auth)
login.chunk.js โ 45KB (loaded when user hits /login)
dashboard.chunk.js โ 380KB (loaded when user navigates to /dashboard)
admin.chunk.js โ 220KB (loaded when admin navigates to /admin)
reports.chunk.js โ 160KB (loaded only if user visits /reports)
User visits the login page. Browser downloads 180KB + 45KB = 225KB. Everything else loads on demand as the user navigates.

React.lazy โ the built-in solution
React.lazy lets you import a component dynamically. The component's code is loaded only when it's first rendered.
import { lazy, Suspense } from 'react';
// โ Eager import โ Dashboard code is in the main bundle regardless
import { DashboardPage } from './pages/DashboardPage';
// โ
Lazy import โ Dashboard code loads only when this component is rendered
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
React.lazy always works with Suspense. Suspense shows a fallback while the chunk is loading:
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Spinner } from '@shared/components';
// Lazy import every page
const LoginPage = lazy(() => import('./pages/LoginPage'));
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const ProductsPage = lazy(() => import('./pages/ProductsPage'));
const AdminPage = lazy(() => import('./pages/AdminPage'));
const ReportsPage = lazy(() => import('./pages/ReportsPage'));
export function App() {
return (
<BrowserRouter>
<Suspense fallback={<Spinner fullPage />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/products" element={<ProductsPage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/reports" element={<ReportsPage />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}

Now each page is its own chunk. Navigating to /dashboard for the first time triggers a network request for DashboardPage's chunk. Suspense shows the spinner while it loads โ then renders the page.
๐ก Route-level splitting is the highest-impact change you can make. Every page becomes its own chunk. Users only download code for pages they visit.

Granular splitting โ lazy loading heavy components
Not just pages. Any heavy component that isn't needed on first render is a splitting candidate.
// A charting library โ very heavy, only needed on the reports page
const RevenueChart = lazy(() => import('./components/RevenueChart'));
const UserGrowthChart = lazy(() => import('./components/UserGrowthChart'));
function ReportsPage() {
const [activeTab, setActiveTab] = useState<'revenue' | 'users'>('revenue');
return (
<div>
<TabBar active={activeTab} onChange={setActiveTab} />
<Suspense fallback={<ChartSkeleton />}>
{activeTab === 'revenue' && <RevenueChart />}
{activeTab === 'users' && <UserGrowthChart />}
</Suspense>
</div>
);
}
The charting library (Recharts, Chart.js, etc.) can be 200โ400KB. If it's only on the reports page โ there's no reason it's in the main bundle.

Named exports and lazy โ the gotcha
React.lazy only works with default exports. If your component uses a named export, you need a wrapper:
// โ Won't work โ lazy requires a default export
const { ProductList } = lazy(() => import('./components/ProductList'));
// โ
Fix option 1 โ add default export to the file
// ProductList.tsx
export default function ProductList() { /* ... */ }
// โ
Fix option 2 โ re-export as default in the lazy import
const ProductList = lazy(() =>
import('./components/ProductList').then(module => ({
default: module.ProductList,
}))
);
The .then() pattern is useful when you can't modify the source file (third-party components, existing code).

Suspense boundaries โ where to put them
Suspense can be nested. Use multiple boundaries to control what shows a spinner and what doesn't.
// One Suspense at the top โ entire page goes blank while chunk loads
function App() {
return (
<Suspense fallback={<FullPageSpinner />}>
<Routes>...</Routes>
</Suspense>
);
}

// Nested Suspense โ shell renders immediately, content streams in
function DashboardPage() {
return (
<DashboardShell> {/* renders immediately โ no lazy */}
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel /> {/* lazy โ loads its own chunk */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart /> {/* lazy โ loads separately */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrdersTable /> {/* lazy โ loads separately */}
</Suspense>
</DashboardShell>
);
}
With nested boundaries, the page shell appears instantly. Each section loads independently and replaces its skeleton when ready. Users see content progressively โ much better experience than waiting for everything at once.

Preloading โ loading chunks before they're needed
The downside of lazy loading: there's a delay when the user first navigates to a lazy route. Small โ usually 100โ300ms โ but perceptible.
You can preload chunks before the user actually navigates โ triggered by hover or focus:
// Preload on hover โ chunk loads while user is moving their mouse
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
// Create a preload function
function preloadDashboard() {
import('./pages/DashboardPage'); // triggers the network request
}
function Navbar() {
return (
<nav>
<Link
to="/dashboard"
onMouseEnter={preloadDashboard} // preload on hover
onFocus={preloadDashboard} // preload on keyboard focus
>
Dashboard
</Link>
</nav>
);
}
By the time the user clicks, the chunk is already loaded. Zero perceived delay.
Dynamic imports for non-component code
React.lazy is for components. But you can dynamically import anything โ libraries, utilities, large data.
// Only load the PDF generation library when the user clicks "Export"
async function handleExportPDF() {
const { generatePDF } = await import('./utils/pdfGenerator');
await generatePDF(reportData);
}
// Only load a large locale file when needed
async function loadLocale(locale: string) {
const { messages } = await import(`./locales/${locale}.json`);
return messages;
}
// Only load a heavy validation library when the form is submitted
async function handleFormSubmit(data: FormData) {
const { validate } = await import('heavy-validation-library');
const errors = await validate(data);
if (errors.length > 0) return setErrors(errors);
await submitForm(data);
}
This pattern is especially useful for:
- Export features (PDF, Excel) โ heavy libraries only when export is triggered
- Rich text editors โ load only when user clicks "edit"
- Maps โ load the mapping library only when the map component is rendered
- Large datasets โ load only when the user requests them
Measuring โ how to know if it's working
After adding code splitting, verify it's actually working:
In the browser:
- Open DevTools โ Network tab
- Filter by JS
- Navigate to different routes
- Watch new chunk files download as you navigate
With Vite's bundle analyser:
npm install --save-dev rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
visualizer({ open: true }), // opens a visual bundle map after build
],
});
npm run build
The visualiser shows you exactly what's in each chunk. If you see chart.js in your main bundle when it should only be on the reports page โ you have a missed split.
Common mistakes
Mistake 1 โ Splitting too granularly
// โ Splitting a tiny component โ chunk overhead costs more than it saves
const MySmallButton = lazy(() => import('./SmallButton')); // 2KB component
Network overhead for a chunk request is ~20ms minimum. For tiny components, the loading delay is worse than just including them in the main bundle. Split components that are > 30KB or that are heavy dependencies (chart libraries, editors, maps).
Mistake 2 โ No loading fallback
// โ No fallback โ user sees nothing while chunk loads
<Suspense>
<HeavyComponent />
</Suspense>
// โ
Always provide a meaningful fallback
<Suspense fallback={<Skeleton height={400} />}>
<HeavyComponent />
</Suspense>
Mistake 3 โ Lazy importing inside a component
// โ This creates a new lazy component on every render โ loses the cache
function MyPage() {
const Chart = lazy(() => import('./Chart')); // wrong โ inside component
return <Chart />;
}
// โ
Always define lazy imports at module level
const Chart = lazy(() => import('./Chart')); // correct โ outside component
function MyPage() {
return <Chart />;
}
Today's takeaway

Code splitting is one of the highest-ROI performance improvements you can make. Route-level splitting alone can cut your initial bundle by 60โ80%.
The pattern is simple:
lazy()for the importSuspensewith a meaningful fallback- Preload on hover for zero perceived delay
Start with route-level splitting. Then identify heavy components (chart libraries, rich text editors, maps) and split those next. Use the bundle visualiser to find what's still in the main bundle that shouldn't be.

See you tomorrow for Day 23 โ List virtualisation: rendering 10,000 rows without killing the browser.
โ Day 21 โ Rendering Patterns โ Day 23 โ List Virtualisation
Part of the React System Design Series โ 40 days, one topic per day.







