π What You Will Learn
- Why bundle size kills first load: what a 2 MB
main.jsactually costs users on real networks - Dynamic import(): how bundlers turn one import into a separate, on-demand chunk
- React.lazy + Suspense: rendering a component whose code hasn't downloaded yet β with a proper fallback
- Route-based splitting: the 80/20 win with React Router and Next.js
dynamic() - Component-based splitting: heavy modals, charts, and editors that load on interaction
- Preloading on intent: warming a chunk on hover so the fallback never flashes
- Error handling: chunks fail on flaky networks β error boundaries + a retry pattern
- Measuring the win: source-map-explorer, build output, and Lighthouse before/after
Prerequisites: comfortable with components, props, state, and rendering β the React Cheatsheet and React Hooks posts cover everything this one builds on.
The Bundle-Size Problem
Here's the uncomfortable truth about a typical React SPA: the browser downloads, parses, and executes your entire application before the user sees anything interactive. Every page. Every modal. Every chart. Even the admin screen that 2% of users ever visit. Lahat, sabay-sabay, on first load.
On your office WiFi with an M-series laptop, a 2 MB bundle feels fine. On a mid-range Android phone on mobile data β which is most real users in the Philippines β that same bundle means 3β8 seconds of blank or frozen page: download time, then JS parse/execute time, which on low-end CPUs is often the bigger cost.
Run a bundle analyzer against a typical dashboard app and you'll see something like this:
main.js β 2.1 MB (mock source-map-explorer breakdown)
Look at the amber bars: roughly 80% of that JavaScript is not needed to render the first screen. The charting library matters only on the reports page. The rich-text editor matters only when someone writes a post. The PDF export matters only when someone clicks "Export". Yet all of it blocks first paint.
What Code Splitting Actually Does
Code splitting is a bundler feature (webpack, Vite/Rollup, Turbopack β all support it) that React plugs into. The trigger is the syntax you use to import a module:
// STATIC import β resolved at build time.
// Chart.js and everything it imports goes INTO main.js.
import { Chart } from './Chart';
// DYNAMIC import() β a function call that returns a Promise.
// The bundler sees this and emits Chart (+ its dependencies)
// as a SEPARATE file: chart.[hash].chunk.js
const loadChart = () => import('./Chart');
// Nothing downloads until you actually call it:
loadChart().then((module) => {
console.log('chunk arrived:', module.Chart);
});That's the whole trick. A static import says "bundle this with me." A dynamic import() says "cut here β make this its own chunk, and give me a Promise that resolves when it's downloaded." The bundler also splits out any dependency that only the lazy module uses, which is where the big wins come from.
The network waterfall changes from one giant blocking download into a small critical bundle plus on-demand chunks:
WITHOUT code splitting
ββββββββββββββββββββββ
main.js (2.1 MB) βββββββββββββββββββββββββββββββββ 4.8s
β² first paint here π©
WITH code splitting
βββββββββββββββββββ
main.js (410 KB) βββββββ 1.1s
β² first paint here π
reports.chunk.js Β·Β·Β·Β·Β·Β· ββββ 0.6s β only when user opens /reports
editor.chunk.js Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β· βββ 0.4s β only when user starts writingReact.lazy Basics
import() gives you a Promise of a module β but React renders components, not Promises. React.lazy() is the adapter: it wraps a dynamic import and gives you back a component you can render like any other. The first time it renders, React kicks off the chunk download behind the scenes.
import { lazy } from 'react';
// lazy() takes a function that returns import()'s Promise.
// It must resolve to a module with a DEFAULT export
// that is a React component.
const GreetingCard = lazy(() => import('./GreetingCard'));
// From here, <GreetingCard /> works like a normal component β
// props and all. React just downloads its code on first render.Try it below. The Demo tab simulates the chunk download with a setTimeout-based promise (this blog page can't really split its own chunks mid-article!), but the Code tab shows the exact React.lazy code you'd write β and the phases you see (not downloaded β downloading β rendered) are exactly what happens on the wire.
(Simulated: the "chunk download" is a 1.5s setTimeout so you can see each phase.)
greeting-card.chunk.js β not downloaded yet (0 KB on the wire)
<Suspense> above it, React throws: A component suspended while responding to synchronous input (or in older versions, an error asking for a Suspense boundary). lazy and Suspense are a pair β never ship one without the other.Suspense & Fallback UI
<Suspense> is React's "while you wait" boundary. When any lazy component inside it is still downloading, React renders the fallback prop instead of the subtree. The moment the chunk resolves, React swaps the real component in.
The demo below simulates a slow 3G chunk (2.5 seconds) so you can actually watch the fallback do its job. Notice two things: the spinner holds the space so nothing jumps, and after you unmount and remount, the component appears instantly β the chunk is cached, exactly like a real one would be.
(Simulated slow 3G: the chunk takes 2.5s. Unmount then mount again β the second render is instant, because real chunks are cached too.)
Dashboard is not mounted. Its JavaScript stays off the critical path.
Where you put the boundary matters. One <Suspense> at the app root means any lazy load blanks the whole screen. Boundaries close to the lazy content keep the rest of the page interactive:
// β One giant boundary: opening the chart blanks EVERYTHING
<Suspense fallback={<FullPageSpinner />}>
<Header />
<Sidebar />
<SalesChart /> {/* lazy */}
</Suspense>
// β
Local boundary: header and sidebar stay put,
// only the chart area shows a skeleton
<Header />
<Sidebar />
<Suspense fallback={<ChartSkeleton />}>
<SalesChart /> {/* lazy */}
</Suspense>Route-Based Splitting β the 80/20 Win
If you do only one thing from this post, do this: lazy-load your routes. Users visit one page at a time, so pages are natural split points β and it's usually a 10-minute change that cuts the initial bundle by half or more.
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// Each page becomes its own chunk
const HomePage = lazy(() => import('./pages/HomePage'));
const ReportsPage = lazy(() => import('./pages/ReportsPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const AdminPage = lazy(() => import('./pages/AdminPage'));
export function App() {
return (
<BrowserRouter>
{/* One boundary around the route outlet is enough:
navigation swaps the whole page area anyway */}
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/admin" element={<AdminPage />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}Using Next.js (like this blog)? The App Router already splits per route automatically β every page.tsx is its own chunk. For splitting within a page, Next ships next/dynamic, which is lazy + Suspense in one call:
import dynamic from 'next/dynamic';
// Same idea as lazy(), with the fallback built in
const SalesChart = dynamic(() => import('@/components/SalesChart'), {
loading: () => <ChartSkeleton />,
ssr: false, // skip server-rendering for browser-only libs (canvas, window)
});
export default function ReportsPage() {
return (
<main>
<h1>Reports</h1>
<SalesChart />
</main>
);
}Suspense covers all of them, and the fallback shows during navigation when users already expect a transition. Component-level splitting (next section) is where you get surgical.Component-Based Splitting: Heavy Modals, Charts & Editors
After routes, hunt for heavy components behind an interaction. The pattern: the component only renders after a click (open modal, switch tab, expand panel), and it drags in a fat dependency. Classic suspects:
- π Charts β recharts / chart.js / echarts (300β700 KB)
- π Rich-text editors β TipTap, Quill, Slate (400 KB+)
- πΊοΈ Maps β mapbox-gl, leaflet (200β800 KB)
- π PDF / export tooling β jspdf, xlsx (300 KB+)
- πΌοΈ Image croppers, video players, emoji pickers
The demo simulates a reports page where the chart library ("380 KB") only downloads when the user clicks Open sales chart β with a skeleton holding the layout during the fake 1.8s download:
(Simulated: the "380 KB chart chunk" is a 1.8s setTimeout. Note the skeleton keeps the layout stable while it loads.)
Users who never open the chart never pay for the charting library. π
SalesChart.jsx only helps if the heavy library is imported inside SalesChart.jsx. If ReportsPage also does import { BarChart } from 'recharts' at the top, the library still lands in the parent chunk and you saved nothing. Check the analyzer, not your assumptions.Preloading on Hover β Loading with Intent
Code splitting has one honest downside: the first click on a split feature waits for the network. The pro move is to start the download when the user signals intent β hovering the button, focusing it, scrolling near it β so by the time they click, the chunk is already there.
The key insight: import() is just a function. Call it early to warm the cache; lazy() will reuse the same module. Hover the button below, watch the status flip to chunk loaded, then click β instant. Reset and click cold to feel the difference.
(Simulated 1.6s chunk. Hover the button first, wait for "chunk loaded", then click β the panel opens instantly. Reset and click without hovering to feel the difference.)
The same trick generalizes beyond hover:
const editorImport = () => import('./RichTextEditor');
const RichTextEditor = lazy(editorImport);
// 1. On hover / focus (shown in the demo)
<button onMouseEnter={editorImport} onFocus={editorImport}>
Write a post
</button>
// 2. When the browser is idle after first paint
useEffect(() => {
const id = requestIdleCallback(() => editorImport());
return () => cancelIdleCallback(id); // cleanup if we unmount first
}, []); // run once after mount
// 3. When a trigger scrolls into view
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) editorImport();
});
observer.observe(buttonRef.current);
return () => observer.disconnect(); // always clean up observers
}, []); // observe once; the ref doesn't changeError Handling: Chunks Can Fail
Dito nagkakatalo ang mga tutorial at ang production. A chunk download is a network request, and network requests fail: flaky mobile data, a train tunnel, or the classic β you deployed while the user had the old page open, and the old chunk filename (with its old hash) no longer exists on the server. The Promise rejects, the lazy component throws during render, and without protection your whole app white-screens.
Suspense handles waiting; an error boundary handles failing. Error boundaries are still class components (the one legitimate class left in modern React):
import { Component } from 'react';
// The one place classes still earn their keep in modern React
export class ChunkErrorBoundary extends Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error, info) {
// Report to Sentry/monitoring β chunk failures spike after deploys
console.error('Chunk failed to load:', error, info);
}
handleRetry = () => {
// Clearing the error re-renders children β lazy retries the import
this.setState({ hasError: false });
};
render() {
if (this.state.hasError) {
return (
<div role="alert">
<p>This section failed to load. Check your connection?</p>
<button onClick={this.handleRetry}>Try again</button>
</div>
);
}
return this.props.children;
}
}Stack the two boundaries: error boundary outside, Suspense inside. For flaky-network resilience, add automatic retries with backoff around the import itself:
import { lazy } from 'react';
export function lazyWithRetry(importFn, retries = 2) {
return lazy(async () => {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await importFn();
} catch (error) {
if (attempt === retries) throw error; // let the boundary catch it
// Back off: 1s, then 2s, then give up
await new Promise((resolve) =>
setTimeout(resolve, 1000 * (attempt + 1))
);
}
}
});
}
// Usage β drop-in replacement for lazy():
const SalesChart = lazyWithRetry(() => import('./SalesChart'));
export function ReportsSection() {
return (
<ChunkErrorBoundary>
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
</ChunkErrorBoundary>
);
}window.location.reload() to pick up the new HTML β guarded by a sessionStorage flag so you never reload-loop.The Named-Exports Gotcha
React.lazy has one strict rule that bites almost everyone once: the imported module must have a default export, and it must be a component. If your codebase uses named exports (many do, and that's fine!), lazy resolves to a module object with no default β and you get the confusing Element type is invalid error at runtime.
// Chart.jsx uses a NAMED export:
export function Chart({ data }) { /* ... */ }
// β BREAKS β lazy looks for module.default and finds undefined:
// "Element type is invalid: expected a string or a class/function
// but got: undefined."
const Chart = lazy(() => import('./Chart'));
// β
FIX 1: map the named export to a default in .then()
const Chart = lazy(() =>
import('./Chart').then((module) => ({ default: module.Chart }))
);
// β
FIX 2: give lazy-loaded components a default export
// Chart.jsx:
export default function Chart({ data }) { /* ... */ }
const Chart = lazy(() => import('./Chart'));The .then((m) => ({ default: m.X })) trick also lets you split one export out of a barrel module β handy for icon or component libraries where you want a single heavy piece:
// Pull just the heavy DataGrid out of a component library
const DataGrid = lazy(() =>
import('@my-org/ui-kit').then((module) => ({
default: module.DataGrid,
}))
);
// Careful: this only creates a separate chunk if the library
// is tree-shakeable (ESM). Otherwise the whole kit comes along.default export, and the file name matches the component name (SalesChart.jsx β export default SalesChart). Boring and consistent beats clever.Measuring the Win
Never split blind. Measure before, split, measure after β otherwise you're adding Suspense boundaries for vibes.
npm run build
npx source-map-explorer 'dist/assets/*.js'
# Opens a treemap of every byte in the bundle, by source file.
# The biggest rectangles are your splitting candidates.
# (Vite alternative: npx vite-bundle-visualizer)Route (app) Size First Load JS
β β / 5.2 kB 92 kB
β β /reports 8.9 kB 101 kB
β β /settings 3.1 kB 89 kB
β β /admin 6.4 kB 95 kB
+ First Load JS shared by all 84 kB
# "First Load JS" is the number that matters for first paint.
# Before splitting the chart, /reports sat at 487 kB.Run Lighthouse (Chrome DevTools β Lighthouse tab, mobile preset) before and after. Realistic numbers from splitting a 2.1 MB dashboard build β routes first, then the chart and editor:
| Metric (mobile, slow 4G) | Before | After | Change |
|---|---|---|---|
| Initial JS transferred | 2.1 MB | 410 KB | β80% |
| First Contentful Paint | 3.8 s | 1.4 s | β63% |
| Time to Interactive | 7.2 s | 2.6 s | β64% |
| Total Blocking Time | 1,840 ms | 390 ms | β79% |
| Lighthouse Performance | 41 | 88 | +47 pts |
Common Mistakes
lazy() during render creates a new component type every render. React sees a different type, unmounts the old tree, re-suspends, re-rendersβ¦ and your component flashes forever. lazy() belongs at module scope, outside every component.// β BAD: new lazy component created on EVERY render β
// state inside SalesChart is destroyed each time, fallback flashes
function ReportsPage() {
const SalesChart = lazy(() => import('./SalesChart'));
return (
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
);
}
// β
GOOD: declared once, at module scope
const SalesChart = lazy(() => import('./SalesChart'));
function ReportsPage() {
return (
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
);
}Button trades a few kilobytes for an extra request, a Suspense fallback flash, and more moving parts. Rule of thumb: don't bother below ~30β50 KB unless the component is truly rare (an error page, a one-per-year admin tool).ReportsPage renders lazy SalesChart on mount, the chart's download only starts after the page's download finishes β a serial waterfall. Either let the inner component ride in the page's chunk (if it renders immediately anyway), keep the inner split but gate it behind interaction, or preload the inner chunk in parallel.// β Serial: reports.chunk.js finishes β THEN chart.chunk.js starts
const ReportsPage = lazy(() => import('./ReportsPage'));
// inside ReportsPage.jsx:
const SalesChart = lazy(() => import('./SalesChart')); // renders on mount
// β
Parallel: warm both chunks when navigation starts
const reportsImport = () => import('./ReportsPage');
const chartImport = () => import('./SalesChart');
function ReportsLink() {
const warmBoth = () => {
reportsImport();
chartImport(); // downloads side by side, not in sequence
};
return (
<Link to="/reports" onMouseEnter={warmBoth} onFocus={warmBoth}>
Reports
</Link>
);
}// β BAD: lazy component with no <Suspense> ancestor β
// React throws when the chunk hasn't arrived yet
function App() {
return <SalesChart />;
}
// β
GOOD: every lazy component renders under a boundary
function App() {
return (
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
);
}Best Practices β What to Split vs What to Keep
| Candidate | Split it? | Why |
|---|---|---|
| Routes / pages | β Always | Natural boundaries; users load one page at a time |
| Chart / editor / map libraries | β Yes | Huge deps, used by a fraction of sessions |
| Modals & drawers opened by click | β Yes | Interaction gives you a free loading moment |
| Admin / settings areas | β Yes | Small fraction of users ever visit them |
| Below-the-fold marketing sections | π€ Maybe | Split if heavy; pair with an in-view preload |
| Header, nav, layout shell | β No | Needed for first paint β splitting delays it |
| Buttons, inputs, small shared UI | β No | Chunk overhead outweighs a few KB saved |
| Anything above the fold on '/' | β No | You'd trade paint time for a spinner |
| API | Signature | Notes |
|---|---|---|
| import() | import('./Module') β Promise<Module> | The bundler split point; call early to preload |
| lazy() | lazy(() => import('./X')) β Component | Module must default-export a component |
| <Suspense> | <Suspense fallback={<UI />}>β¦</Suspense> | Shows fallback while any child chunk loads |
| Named-export trick | .then((m) => ({ default: m.X })) | Adapts named exports for lazy() |
| next/dynamic | dynamic(() => import('./X'), { loading, ssr }) | Next.js: lazy + Suspense + SSR control in one |
Practice Project + What's Next
Cement this with a small "Insights" dashboard:
- Three routes β
/,/reports,/settingsβ each lazy-loaded behind one<Suspense>with a page skeleton - On
/reports, a chart component (use any chart lib, or fake one with divs like this post did) that loads only when the user clicks "Show chart", with a skeleton fallback - Preload the reports chunk on
onMouseEnter/onFocusof its nav link - Wrap the chart in a
ChunkErrorBoundarywith a retry button β test it by throttling to "Offline" in DevTools mid-load - Run
source-map-explorerbefore and after, and write down the First Load JS numbers β proof of your win
- React.memo β you've cut download cost; now cut re-render cost for components that receive the same props
- useMemo β cache expensive computations (like transforming that chart data) between renders
- Data Fetching β chunks aren't the only thing that loads async; master fetching states, races, and caching next
Keep shipping less! πͺ Code splitting is one of the rare optimizations that's both massive and low-risk: your code barely changes, but your users β especially the ones on mid-range phones and mobile data β feel it on every first visit. Split the routes today; everything else in this post is bonus points.