Code Splitting, Lazy Loading and Suspense Boundaries
Why Initial Bundle Size Determines First Impressions
Your application can have excellent runtime performance and still hemorrhage users before they ever interact with it. Google's research consistently shows that every additional second of load time reduces mobile conversions by around 20%. The problem isn't slow React rendering — it's the wall of JavaScript that has to download, parse, and execute before the browser can show anything.
Traditional SPA build configurations bundle every route, every component, every third-party library into a single JavaScript file. A user visits your landing page and their browser silently downloads the admin dashboard code, the PDF viewer, the data visualization library, and the report exporter — none of which they asked for or will use in this session. Code splitting is the solution: send users only the code they need for what they're doing right now.
The Metrics That Matter
Before diving into implementation, clarify what you're actually trying to improve:
LCP (Largest Contentful Paint): The time until the largest element visible in the viewport (typically a hero image or headline) finishes rendering. Reducing JavaScript parse time unblocks the main thread earlier, indirectly improving LCP.
TTI (Time to Interactive): The time from navigation start until the page can reliably respond to user interactions (clicks, keyboard input). This is the metric code splitting most directly affects — less initial JavaScript means the main thread becomes available sooner.
INP (Interaction to Next Paint): The latency of the slowest interaction in a session. Large JavaScript bundles cause long tasks that block the main thread and inflate INP.
A concrete before/after from a real e-commerce application:
Before code splitting:
Initial bundle: 1.2MB raw, 380KB gzip
TTI on 4G: 6.8 seconds
LCP: 4.2 seconds
Core Web Vitals: Poor
After route-level code splitting:
Initial bundle: 320KB raw, 98KB gzip
TTI on 4G: 2.1 seconds (-69%)
LCP: 2.8 seconds (-33%)
Core Web Vitals: Good
The bundle didn't get smaller in total — the code still exists. It just loads on demand, when the user navigates to a route that needs it.
React.lazy and dynamic import(): The Mechanism
React.lazy is React's built-in API for component-level code splitting. It works by wrapping JavaScript's import() dynamic import syntax.
// Static import: bundled at build time into the main chunk
import HeavyDashboard from './HeavyDashboard';
// Dynamic import: split into a separate chunk, loaded on first render
const HeavyDashboard = React.lazy(() => import('./HeavyDashboard'));
React.lazy takes a function (not a Promise directly) that returns a Promise resolving to { default: Component }. The function is called the first time the component is about to render — not when the module loads, not when the component is defined.
When Vite or webpack sees a dynamic import(), it automatically creates a separate output chunk:
dist/
assets/index-BkzP1a2x.js # Main bundle: routing, app shell
assets/Dashboard-Cn8xK9mQ.js # Lazy chunk: loaded when /dashboard is visited
assets/Settings-Dz2vM7nR.js # Lazy chunk: loaded when /settings is visited
assets/recharts-Ex4wP3oS.js # Vendor chunk: shared library
Controlling Chunk Names
// Webpack magic comment for named chunks
const Dashboard = React.lazy(
() => import(/* webpackChunkName: "dashboard" */ './pages/Dashboard')
);
// Vite: configure manualChunks in vite.config.ts for vendor libraries
// For app chunks, Vite generates names from the file path automatically
Suspense Boundary Placement Strategy
React.lazy requires a <Suspense> ancestor. When a lazy component is downloading, React walks up the tree to find the nearest <Suspense> boundary and renders its fallback prop instead. Where you place Suspense boundaries determines everything about the user experience during loading.
Too High: Full-Page Interruption
// ❌ Single boundary at the app root
function App() {
return (
<Suspense fallback={<div className="spinner" />}>
<Router>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Router>
</Suspense>
);
}
When a user navigates to /dashboard, the entire UI — navigation bar, sidebars, everything — disappears and is replaced by a spinner. The layout "blinks." This is worse than a page reload because at least a full reload shows a browser loading indicator.
Too Low: Cascading Spinners
// ❌ Every sub-component has its own boundary
function Dashboard() {
return (
<div className="dashboard-grid">
<Suspense fallback={<Spinner />}><LazyChart /></Suspense>
<Suspense fallback={<Spinner />}><LazyTable /></Suspense>
<Suspense fallback={<Spinner />}><LazySidebar /></Suspense>
</div>
);
}
Multiple spinners appear and disappear independently as each chunk finishes loading. This "waterfall" of loading states looks like a series of bugs, not intentional design. The constant layout shifts also damage CLS (Cumulative Layout Shift) scores.
The Right Approach: Match Content Units
// ✅ Route-level boundary: keeps app shell visible, replaces content area
function App() {
return (
<div className="app-shell">
<NavigationBar /> {/* Always visible — not inside Suspense */}
<Sidebar /> {/* Always visible — not inside Suspense */}
<main>
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</main>
</div>
);
}
// ✅ Feature-level boundary within a page: only the lazy feature shows loading
function Dashboard() {
return (
<div className="dashboard">
<DashboardStats /> {/* Immediate: lightweight, always renders fast */}
<Suspense fallback={<ChartSkeleton />}>
<LazyAnalyticsChart /> {/* Heavy: chart library + data, shows skeleton */}
</Suspense>
</div>
);
}
The guiding principle: Suspense boundaries should align with user-perceived content units, not with technical implementation boundaries. The navigation, breadcrumbs, and page frame should always be visible. The content region the user navigated to see is the appropriate boundary location.
Fallback UI Design: Skeletons Beat Spinners
A spinner tells the user "something is loading." A skeleton screen tells them "this specific content is loading, and here's roughly where it will appear." The difference matters for both perceived performance and layout stability.
// ❌ Minimal spinner: causes layout shifts, no content anticipation
<Suspense fallback={<div className="spinner-overlay" />}>
// ✅ Skeleton: preserves layout, communicates content structure
function ArticleCardSkeleton() {
return (
<article className="article-card skeleton" aria-hidden="true">
<div
className="skeleton-block"
style={{ height: 200, borderRadius: 8 }}
/>
<div className="skeleton-lines">
<div className="skeleton-line" style={{ width: '80%' }} />
<div className="skeleton-line" style={{ width: '60%' }} />
<div className="skeleton-line" style={{ width: '40%' }} />
</div>
</article>
);
}
<Suspense fallback={<ArticleCardSkeleton />}>
<LazyArticleCard articleId={id} />
</Suspense>
The skeleton CSS uses a shimmer animation (background: linear-gradient moving horizontally via @keyframes) to signal activity. This tells users the page is working, not frozen.
Keep skeleton dimensions close to the actual content dimensions. The goal is to prevent CLS — layout shifts that happen when content loads and shoves other elements around. Google's Core Web Vitals penalize CLS, and skeleton screens directly mitigate it.
Suspense + Error Boundaries: Defense in Depth
Network requests fail. CDNs have outages. Dynamic import() calls that fail cause the returned Promise to reject. Without an Error Boundary, this unhandled rejection crashes your entire React tree.
import { Component, ReactNode, ErrorInfo } from 'react';
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ChunkLoadErrorBoundary extends Component<
{ children: ReactNode; fallback?: ReactNode; onError?: (error: Error) => void },
ErrorBoundaryState
> {
state: ErrorBoundaryState = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
this.props.onError?.(error);
// Send to error monitoring (Sentry, DataDog, etc.)
console.error('Chunk load failed:', error, info);
}
handleRetry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
return this.props.fallback ?? (
<div className="load-error" role="alert">
<p>Failed to load this section.</p>
<button onClick={this.handleRetry}>Try Again</button>
</div>
);
}
return this.props.children;
}
}
// Usage: Error Boundary wraps Suspense (not the other way around)
function SafeLazyPage({ Component }: { Component: React.LazyExoticComponent<React.ComponentType> }) {
return (
<ChunkLoadErrorBoundary
fallback={<PageErrorFallback />}
onError={error => reportToMonitoring(error)}
>
<Suspense fallback={<PageSkeleton />}>
<Component />
</Suspense>
</ChunkLoadErrorBoundary>
);
}
The layering order matters: Error Boundary outside, Suspense inside. Suspense handles the "loading" state; Error Boundary handles the "failed" state. If Suspense were outside Error Boundary, a load failure that happened before the component mounted would be outside Suspense's handling scope.
Prefetching Strategies: Hover to Prefetch
Code splitting introduces a new problem: the first navigation to a lazy route has a noticeable delay as the browser downloads the chunk. Users click a link and wait. This undermines the performance improvements code splitting is supposed to deliver.
The solution: prefetch the chunk before the user explicitly navigates. The best trigger is mouse hover — research shows the average time between hovering over a link and clicking it is 150-400ms. That's enough time to download a typical route chunk over a 4G connection.
// Manual prefetch hook
function usePrefetch(importFn: () => Promise<unknown>) {
const prefetch = useCallback(() => {
// Calling import() a second time after the first download is a no-op
// (browsers and bundlers cache the result)
importFn().catch(() => {
// Prefetch failures are silent — they don't affect navigation
// If the chunk fails here, it will fail again on navigation and
// the Error Boundary will handle it then
});
}, [importFn]);
return prefetch;
}
// NavLink component with built-in prefetching
function PrefetchLink({
to,
label,
lazyImport,
}: {
to: string;
label: string;
lazyImport: () => Promise<unknown>;
}) {
const prefetch = usePrefetch(lazyImport);
return (
<Link
to={to}
onMouseEnter={prefetch}
onFocus={prefetch} // Keyboard navigation also benefits
>
{label}
</Link>
);
}
// Usage
<PrefetchLink
to="/dashboard"
label="Dashboard"
lazyImport={() => import('./pages/Dashboard')}
/>
For touch interfaces (mobile), onMouseEnter doesn't fire. Consider using onTouchStart as an additional trigger, though the timing advantage is smaller since touch interactions are inherently faster (no hover → click gap).
React Router v7+ has built-in prefetching support via the <Link prefetch="intent"> prop, which does exactly this.
Route-Level vs Component-Level Splitting
Route-Level Splitting
The highest ROI strategy. Different routes are almost never needed simultaneously, and route boundaries are natural code boundaries.
// Create all lazy routes upfront at the module level (not inside components)
const Home = React.lazy(() => import('./pages/Home'));
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));
const AdminPanel = React.lazy(() => import('./pages/AdminPanel'));
const Reports = React.lazy(() => import('./pages/Reports'));
// Wire them into the router
const router = createBrowserRouter([
{
path: '/',
element: <AppShell />,
children: [
{ index: true, element: <Home /> },
{ path: 'dashboard', element: <Dashboard /> },
{ path: 'settings', element: <Settings /> },
{ path: 'admin', element: <AdminPanel /> },
{ path: 'reports', element: <Reports /> },
],
},
]);
Every route becomes its own chunk. Users downloading Settings don't pay for AdminPanel code they'll never use.
Component-Level Splitting
For large, optional features within a single route:
// Candidates for component-level splitting:
// - Rich text editors (Quill, TipTap, Slate): 200-500KB
// - Code editors (Monaco): 2-4MB
// - PDF viewers: 300-800KB
// - Map components (Leaflet, Mapbox): 200-600KB
// - Chart libraries (Recharts, Victory): 100-300KB
const MonacoEditor = React.lazy(() => import('./components/CodeEditor'));
const PDFViewer = React.lazy(() => import('./components/PDFViewer'));
function DocumentPage({ doc }: { doc: Document }) {
const [isEditing, setIsEditing] = useState(false);
const [showPreview, setShowPreview] = useState(false);
return (
<div>
<DocumentHeader doc={doc} onEdit={() => setIsEditing(true)} />
{isEditing && (
<Suspense fallback={<EditorSkeleton />}>
<MonacoEditor initialValue={doc.content} />
</Suspense>
)}
{showPreview && (
<Suspense fallback={<div style={{ height: 600 }}>Loading preview...</div>}>
<PDFViewer url={doc.pdfUrl} />
</Suspense>
)}
</div>
);
}
Monaco Editor alone is ~4MB unminified. Loading it only when a user actually enters edit mode saves every read-only user from downloading 4MB they don't need.
Vite manualChunks for Vendor Libraries
Control how third-party libraries are grouped into chunks:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// Stable vendor code → long cache TTL
'vendor-react': ['react', 'react-dom'],
'vendor-router': ['react-router-dom'],
'vendor-ui': ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
// Charts separated: only routes that use charts pay for this
'vendor-charts': ['recharts', 'd3-scale', 'd3-shape'],
},
},
},
},
});
The strategy: separate stable vendor code (which rarely changes) from app code (which changes with every deploy). Users who have already visited your app will have vendor chunks cached. Only the small app chunk needs to download on the next visit.
React 19 Suspense Improvements
React 19 refines Suspense behavior in concurrent rendering mode. The key improvement: when a Suspense boundary contains multiple children and some of them suspend, React 19 can render the non-suspended children immediately rather than showing the fallback for the entire boundary.
React 19's use() API also makes Suspense-driven data fetching more natural:
import { use, Suspense } from 'react';
// This component suspends while the promise is pending
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // Suspends until resolved
return (
<div className="profile">
<img src={user.avatarUrl} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.bio}</p>
</div>
);
}
function ProfilePage({ userId }: { userId: string }) {
// Start the fetch immediately, pass the promise down
const userPromise = useMemo(() => fetchUser(userId), [userId]);
return (
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
The use() hook integrates with Suspense in a way that useEffect-based data fetching cannot: the component suspends during the first render rather than rendering a loading state then switching to content (which causes layout shifts and double-render overhead).
Summary
Code splitting reduces initial JavaScript download, parse, and execution time, directly improving TTI and LCP. React.lazy with dynamic import() is the standard implementation mechanism — bundlers automatically create separate chunks. Suspense boundary placement determines the scope and granularity of loading states: boundaries should align with user-perceived content units, keeping app shell elements visible while only the loading content shows a fallback. Skeleton screens outperform spinners by preserving layout and setting content expectations. Error Boundaries must wrap Suspense boundaries to catch chunk load failures. Hover-to-prefetch eliminates the delay on first navigation. Route-level splitting delivers the highest ROI; component-level splitting targets large optional features. React 19's use() API and concurrent rendering improvements make Suspense more powerful for both code splitting and data fetching.