Code Splitting Strategies for Large React Applications
When our React application grew to over 500 components and 2.5MB of JavaScript, I learned that code splitting isn't just an optimization—it's essential for maintaining a usable application. A monolithic bundle was killing our performance metrics and user experience.
After implementing strategic code splitting across multiple large-scale applications, I've discovered that the key isn't just splitting code, but splitting it intelligently. Here's everything I've learned about reducing bundle sizes, improving load times, and maintaining excellent user experience in large React applications.
The Bundle Size Reality Check
Before diving into strategies, let's establish baselines. Here's what I measured in a typical large React application:
Before Code Splitting:
- Initial bundle: 2.5MB (gzipped: 850KB)
- Time to Interactive: 4.2s on 3G
- First Contentful Paint: 2.8s
- Bundle chunks: 3 (main, vendor, runtime)
After Strategic Code Splitting:
- Initial bundle: 380KB (gzipped: 120KB)
- Time to Interactive: 1.6s on 3G
- First Contentful Paint: 0.9s
- Bundle chunks: 23 (route-based + component-based)
The difference is dramatic, but achieving this requires the right strategies.
Route-Based Code Splitting: The Foundation
Route-based splitting is your first and most impactful strategy. Split your application at route boundaries to ensure users only download code for the pages they visit.
Basic Route Splitting with React Router
// App.tsx
import { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
// Lazy load route components
const HomePage = lazy(() => import('./pages/HomePage'));
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const ProfilePage = lazy(() => import('./pages/ProfilePage'));
const AdminPage = lazy(() => import('./pages/AdminPage'));
const ReportsPage = lazy(() => import('./pages/ReportsPage'));
// Loading fallback component
const RouteLoader = () => (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-gray-600">Loading...</span>
</div>
);
// Error fallback for failed loads
const RouteError = ({ error, resetErrorBoundary }) => (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-xl font-semibold text-red-600 mb-4">
Failed to load page
</h2>
<p className="text-gray-600 mb-4">{error.message}</p>
<button
onClick={resetErrorBoundary}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Try Again
</button>
</div>
);
function App() {
return (
<div className="app">
<nav>
{/* Navigation component */}
</nav>
<main>
<ErrorBoundary FallbackComponent={RouteError}>
<Suspense fallback={<RouteLoader />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/admin/*" element={<AdminPage />} />
<Route path="/reports" element={<ReportsPage />} />
</Routes>
</Suspense>
</ErrorBoundary>
</main>
</div>
);
}
export default App;
Advanced Route Splitting with Nested Routes
// pages/AdminPage.tsx
import { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
// Lazy load admin sub-routes
const UserManagement = lazy(() => import('./admin/UserManagement'));
const SystemSettings = lazy(() => import('./admin/SystemSettings'));
const Analytics = lazy(() => import('./admin/Analytics'));
const AuditLogs = lazy(() => import('./admin/AuditLogs'));
const AdminLoader = () => (
<div className="flex items-center justify-center p-8">
<div className="animate-pulse text-gray-500">Loading admin panel...</div>
</div>
);
export default function AdminPage() {
return (
<div className="admin-layout">
<aside className="admin-sidebar">
{/* Admin navigation */}
</aside>
<main className="admin-content">
<Suspense fallback={<AdminLoader />}>
<Routes>
<Route path="/users" element={<UserManagement />} />
<Route path="/settings" element={<SystemSettings />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/audit" element={<AuditLogs />} />
</Routes>
</Suspense>
</main>
</div>
);
}
Component-Based Code Splitting
For components that are large, rarely used, or conditionally rendered, component-based splitting can significantly reduce your initial bundle.
Modal and Dialog Splitting
// components/LazyModal.tsx
import { Suspense, lazy, useState } from 'react';
// Lazy load heavy modal content
const UserProfileModal = lazy(() => import('./modals/UserProfileModal'));
const SettingsModal = lazy(() => import('./modals/SettingsModal'));
const ReportModal = lazy(() => import('./modals/ReportModal'));
const ModalLoader = () => (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
<div className="bg-white rounded-lg p-8">
<div className="animate-spin h-6 w-6 border-2 border-blue-600 border-t-transparent rounded-full mx-auto"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
interface LazyModalProps {
type: 'profile' | 'settings' | 'report';
isOpen: boolean;
onClose: () => void;
data?: any;
}
export default function LazyModal({ type, isOpen, onClose, data }: LazyModalProps) {
if (!isOpen) return null;
const renderModal = () => {
switch (type) {
case 'profile':
return <UserProfileModal onClose={onClose} userData={data} />;
case 'settings':
return <SettingsModal onClose={onClose} initialSettings={data} />;
case 'report':
return <ReportModal onClose={onClose} reportData={data} />;
default:
return null;
}
};
return (
<Suspense fallback={<ModalLoader />}>
{renderModal()}
</Suspense>
);
}
// Usage in parent component
function Dashboard() {
const [modalState, setModalState] = useState<{
type: 'profile' | 'settings' | 'report' | null;
data?: any;
}>({ type: null });
const openModal = (type: 'profile' | 'settings' | 'report', data?: any) => {
setModalState({ type, data });
};
const closeModal = () => {
setModalState({ type: null });
};
return (
<div>
<button onClick={() => openModal('profile', userData)}>
View Profile
</button>
<LazyModal
type={modalState.type!}
isOpen={modalState.type !== null}
onClose={closeModal}
data={modalState.data}
/>
</div>
);
}
Feature-Based Component Splitting
// components/DashboardWidgets.tsx
import { Suspense, lazy, useState } from 'react';
// Lazy load expensive widgets
const AnalyticsChart = lazy(() => import('./widgets/AnalyticsChart'));
const DataTable = lazy(() => import('./widgets/DataTable'));
const RealtimeMonitor = lazy(() => import('./widgets/RealtimeMonitor'));
const PerformanceMetrics = lazy(() => import('./widgets/PerformanceMetrics'));
const WidgetSkeleton = () => (
<div className="bg-white rounded-lg p-6 shadow animate-pulse">
<div className="h-4 bg-gray-200 rounded mb-4 w-1/3"></div>
<div className="space-y-2">
<div className="h-3 bg-gray-200 rounded w-full"></div>
<div className="h-3 bg-gray-200 rounded w-5/6"></div>
<div className="h-3 bg-gray-200 rounded w-4/6"></div>
</div>
</div>
);
interface DashboardWidgetsProps {
enabledWidgets: string[];
widgetData: Record<string, any>;
}
export default function DashboardWidgets({
enabledWidgets,
widgetData
}: DashboardWidgetsProps) {
const [loadedWidgets, setLoadedWidgets] = useState<Set<string>>(new Set());
const handleWidgetLoad = (widgetName: string) => {
setLoadedWidgets(prev => new Set([...prev, widgetName]));
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{enabledWidgets.includes('analytics') && (
<Suspense fallback={<WidgetSkeleton />}>
<AnalyticsChart
data={widgetData.analytics}
onLoad={() => handleWidgetLoad('analytics')}
/>
</Suspense>
)}
{enabledWidgets.includes('datatable') && (
<Suspense fallback={<WidgetSkeleton />}>
<DataTable
data={widgetData.table}
onLoad={() => handleWidgetLoad('datatable')}
/>
</Suspense>
)}
{enabledWidgets.includes('realtime') && (
<Suspense fallback={<WidgetSkeleton />}>
<RealtimeMonitor
config={widgetData.realtime}
onLoad={() => handleWidgetLoad('realtime')}
/>
</Suspense>
)}
{enabledWidgets.includes('performance') && (
<Suspense fallback={<WidgetSkeleton />}>
<PerformanceMetrics
metrics={widgetData.performance}
onLoad={() => handleWidgetLoad('performance')}
/>
</Suspense>
)}
</div>
);
}
Smart Preloading and Prefetching
Loading code just-in-time is great, but predicting user behavior and preloading can eliminate loading states entirely.
Route Preloading on Hover
// hooks/useRoutePreloader.ts
import { useCallback, useRef } from 'react';
interface RoutePreloader {
preloadRoute: (routeImport: () => Promise<any>) => void;
preloadOnHover: (routeImport: () => Promise<any>) => {
onMouseEnter: () => void;
onTouchStart: () => void;
};
}
export function useRoutePreloader(): RoutePreloader {
const preloadedRoutes = useRef<Set<string>>(new Set());
const preloadRoute = useCallback(async (routeImport: () => Promise<any>) => {
const routeKey = routeImport.toString();
if (preloadedRoutes.current.has(routeKey)) {
return;
}
try {
await routeImport();
preloadedRoutes.current.add(routeKey);
console.log('Route preloaded successfully');
} catch (error) {
console.warn('Route preload failed:', error);
}
}, []);
const preloadOnHover = useCallback((routeImport: () => Promise<any>) => {
let timeoutId: NodeJS.Timeout;
return {
onMouseEnter: () => {
// Add slight delay to avoid preloading on accidental hover
timeoutId = setTimeout(() => {
preloadRoute(routeImport);
}, 150);
},
onTouchStart: () => {
// Mobile: preload immediately on touch
preloadRoute(routeImport);
},
onMouseLeave: () => {
clearTimeout(timeoutId);
},
};
}, [preloadRoute]);
return { preloadRoute, preloadOnHover };
}
// components/Navigation.tsx
import { Link } from 'react-router-dom';
import { useRoutePreloader } from '../hooks/useRoutePreloader';
export default function Navigation() {
const { preloadOnHover } = useRoutePreloader();
return (
<nav className="flex space-x-6">
<Link
to="/dashboard"
className="nav-link"
{...preloadOnHover(() => import('../pages/DashboardPage'))}
>
Dashboard
</Link>
<Link
to="/reports"
className="nav-link"
{...preloadOnHover(() => import('../pages/ReportsPage'))}
>
Reports
</Link>
<Link
to="/admin"
className="nav-link"
{...preloadOnHover(() => import('../pages/AdminPage'))}
>
Admin
</Link>
</nav>
);
}
Intelligent Component Preloading
// hooks/useIntersectionPreloader.ts
import { useEffect, useRef, useCallback } from 'react';
interface UseIntersectionPreloaderOptions {
threshold?: number;
rootMargin?: string;
preloadDelay?: number;
}
export function useIntersectionPreloader(
componentImport: () => Promise<any>,
options: UseIntersectionPreloaderOptions = {}
) {
const elementRef = useRef<HTMLDivElement>(null);
const hasPreloaded = useRef(false);
const {
threshold = 0.1,
rootMargin = '200px',
preloadDelay = 0
} = options;
const preloadComponent = useCallback(async () => {
if (hasPreloaded.current) return;
hasPreloaded.current = true;
if (preloadDelay > 0) {
setTimeout(async () => {
try {
await componentImport();
console.log('Component preloaded via intersection');
} catch (error) {
console.warn('Component preload failed:', error);
}
}, preloadDelay);
} else {
try {
await componentImport();
console.log('Component preloaded via intersection');
} catch (error) {
console.warn('Component preload failed:', error);
}
}
}, [componentImport, preloadDelay]);
useEffect(() => {
const element = elementRef.current;
if (!element) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
preloadComponent();
}
});
},
{
threshold,
rootMargin,
}
);
observer.observe(element);
return () => {
observer.unobserve(element);
};
}, [preloadComponent, threshold, rootMargin]);
return elementRef;
}
// Usage example
function HomePage() {
const heavyWidgetRef = useIntersectionPreloader(
() => import('./components/HeavyWidget'),
{ rootMargin: '300px', preloadDelay: 500 }
);
return (
<div>
<section>
<h1>Welcome to our app</h1>
<p>Some content here...</p>
</section>
<section>
<p>More content...</p>
</section>
{/* This div triggers preloading when it comes into view */}
<div ref={heavyWidgetRef}>
<Suspense fallback={<div>Loading heavy widget...</div>}>
<HeavyWidget />
</Suspense>
</div>
</div>
);
}
Bundle Analysis and Optimization
Understanding your bundle composition is crucial for effective code splitting.
Webpack Bundle Analysis
// webpack.config.js (or similar analysis script)
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
// ... other config
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
openAnalyzer: false,
reportFilename: 'bundle-report.html',
}),
],
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 10,
},
common: {
minChunks: 2,
chunks: 'all',
enforce: true,
priority: 5,
},
// Split large libraries into separate chunks
react: {
test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
name: 'react',
chunks: 'all',
priority: 20,
},
charts: {
test: /[\\/]node_modules[\\/](recharts|chart\.js|d3)[\\/]/,
name: 'charts',
chunks: 'all',
priority: 15,
},
},
},
},
};
Custom Bundle Analysis Hook
// hooks/useBundleAnalysis.ts
import { useState, useEffect } from 'react';
interface BundleMetrics {
loadedChunks: string[];
chunkSizes: Record<string, number>;
totalSize: number;
loadTimes: Record<string, number>;
}
export function useBundleAnalysis() {
const [metrics, setMetrics] = useState<BundleMetrics>({
loadedChunks: [],
chunkSizes: {},
totalSize: 0,
loadTimes: {},
});
useEffect(() => {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach((entry) => {
if (entry.name.includes('.js') || entry.name.includes('.css')) {
const chunkName = entry.name.split('/').pop() || 'unknown';
const loadTime = entry.responseEnd - entry.requestStart;
setMetrics(prev => ({
...prev,
loadedChunks: [...prev.loadedChunks, chunkName],
loadTimes: {
...prev.loadTimes,
[chunkName]: loadTime,
},
}));
}
});
});
observer.observe({ entryTypes: ['navigation', 'resource'] });
return () => observer.disconnect();
}, []);
const logMetrics = () => {
console.table(metrics.loadTimes);
console.log('Total chunks loaded:', metrics.loadedChunks.length);
console.log('Average load time:',
Object.values(metrics.loadTimes).reduce((a, b) => a + b, 0) /
Object.values(metrics.loadTimes).length
);
};
return { metrics, logMetrics };
}
// Development component to monitor bundle performance
export function BundleMetrics() {
const { metrics, logMetrics } = useBundleAnalysis();
if (process.env.NODE_ENV !== 'development') {
return null;
}
return (
<div className="fixed bottom-4 right-4 bg-black text-white p-4 rounded text-xs">
<div>Chunks loaded: {metrics.loadedChunks.length}</div>
<button
onClick={logMetrics}
className="mt-2 px-2 py-1 bg-blue-600 rounded text-xs"
>
Log Details
</button>
</div>
);
}
Advanced Splitting Patterns
Conditional Component Loading
// components/ConditionalLoader.tsx
import { Suspense, lazy, useEffect, useState } from 'react';
interface ConditionalLoaderProps {
condition: boolean;
loader: () => Promise<{ default: React.ComponentType<any> }>;
fallback?: React.ReactNode;
children: React.ReactNode;
}
export function ConditionalLoader({
condition,
loader,
fallback = <div>Loading...</div>,
children
}: ConditionalLoaderProps) {
const [LazyComponent, setLazyComponent] = useState<React.ComponentType | null>(null);
useEffect(() => {
if (condition && !LazyComponent) {
const Component = lazy(loader);
setLazyComponent(() => Component);
}
}, [condition, loader, LazyComponent]);
if (!condition) {
return <>{children}</>;
}
if (!LazyComponent) {
return <>{fallback}</>;
}
return (
<Suspense fallback={fallback}>
<LazyComponent>{children}</LazyComponent>
</Suspense>
);
}
// Usage: Load admin tools only for admin users
function Dashboard({ user }) {
return (
<div>
<h1>Dashboard</h1>
<ConditionalLoader
condition={user.role === 'admin'}
loader={() => import('./AdminTools')}
fallback={<div>Loading admin tools...</div>}
>
<div>Admin-specific content would be wrapped by AdminTools</div>
</ConditionalLoader>
</div>
);
}
Feature Flag-Based Splitting
// hooks/useFeatureFlag.ts
import { useState, useEffect } from 'react';
interface FeatureFlags {
[key: string]: boolean;
}
export function useFeatureFlag(flagName: string): boolean {
const [isEnabled, setIsEnabled] = useState(false);
useEffect(() => {
// In real app, fetch from feature flag service
const fetchFeatureFlag = async () => {
try {
const response = await fetch(`/api/feature-flags/${flagName}`);
const data = await response.json();
setIsEnabled(data.enabled);
} catch (error) {
console.warn('Feature flag fetch failed:', error);
// Default to false for safety
setIsEnabled(false);
}
};
fetchFeatureFlag();
}, [flagName]);
return isEnabled;
}
// components/FeatureSplitLoader.tsx
import { Suspense, lazy } from 'react';
import { useFeatureFlag } from '../hooks/useFeatureFlag';
interface FeatureSplitLoaderProps {
featureName: string;
componentLoader: () => Promise<{ default: React.ComponentType<any> }>;
fallbackLoader?: () => Promise<{ default: React.ComponentType<any> }>;
loadingFallback?: React.ReactNode;
props?: any;
}
export function FeatureSplitLoader({
featureName,
componentLoader,
fallbackLoader,
loadingFallback = <div>Loading...</div>,
props = {}
}: FeatureSplitLoaderProps) {
const isFeatureEnabled = useFeatureFlag(featureName);
if (isFeatureEnabled) {
const FeatureComponent = lazy(componentLoader);
return (
<Suspense fallback={loadingFallback}>
<FeatureComponent {...props} />
</Suspense>
);
}
if (fallbackLoader) {
const FallbackComponent = lazy(fallbackLoader);
return (
<Suspense fallback={loadingFallback}>
<FallbackComponent {...props} />
</Suspense>
);
}
return null;
}
// Usage example
function ProductPage() {
return (
<div>
<h1>Product Details</h1>
{/* Load new recommendation engine only if feature is enabled */}
<FeatureSplitLoader
featureName="ai-recommendations"
componentLoader={() => import('./AIRecommendations')}
fallbackLoader={() => import('./BasicRecommendations')}
props={{ productId: '123' }}
/>
</div>
);
}
Third-Party Library Optimization
Selective Library Imports
// utils/lazyLibraries.ts
// Instead of importing entire lodash
// import _ from 'lodash'; // DON'T DO THIS
// Import specific functions
import { debounce, throttle, chunk } from 'lodash-es';
// Or use dynamic imports for heavy utilities
export const loadDateUtils = () => import('date-fns');
export const loadChartLibrary = () => import('recharts');
export const loadFormValidator = () => import('yup');
// Wrapper functions for lazy loading
export async function createFormValidator(schema: any) {
const { object, string, number } = await loadFormValidator();
return object(schema);
}
export async function formatDate(date: Date, format: string) {
const dateFns = await loadDateUtils();
return dateFns.format(date, format);
}
// components/LazyChart.tsx
import { Suspense, lazy } from 'react';
const Chart = lazy(async () => {
const chartLib = await import('recharts');
return {
default: ({ data, config }) => (
<chartLib.LineChart data={data} {...config}>
<chartLib.Line dataKey="value" />
<chartLib.XAxis dataKey="name" />
<chartLib.YAxis />
</chartLib.LineChart>
)
};
});
export function LazyChart({ data, config }) {
return (
<Suspense fallback={<div>Loading chart...</div>}>
<Chart data={data} config={config} />
</Suspense>
);
}
Dynamic Polyfill Loading
// utils/polyfills.ts
export async function loadPolyfills() {
const polyfillsNeeded = [];
// Check for IntersectionObserver
if (!window.IntersectionObserver) {
polyfillsNeeded.push(
import('intersection-observer')
);
}
// Check for ResizeObserver
if (!window.ResizeObserver) {
polyfillsNeeded.push(
import('@juggle/resize-observer').then(module => {
window.ResizeObserver = module.ResizeObserver;
})
);
}
// Check for modern array methods
if (!Array.prototype.at) {
polyfillsNeeded.push(
import('core-js/features/array/at')
);
}
if (polyfillsNeeded.length > 0) {
await Promise.all(polyfillsNeeded);
console.log(`Loaded ${polyfillsNeeded.length} polyfills`);
}
}
// App.tsx
import { useEffect, useState } from 'react';
import { loadPolyfills } from './utils/polyfills';
function App() {
const [polyfillsLoaded, setPolyfillsLoaded] = useState(false);
useEffect(() => {
loadPolyfills().then(() => {
setPolyfillsLoaded(true);
});
}, []);
if (!polyfillsLoaded) {
return <div>Loading browser support...</div>;
}
return (
<div className="app">
{/* Your app content */}
</div>
);
}
Performance Monitoring and Optimization
Runtime Performance Tracking
// hooks/useChunkLoadingMetrics.ts
import { useState, useEffect } from 'react';
interface ChunkMetrics {
chunkName: string;
loadTime: number;
size?: number;
success: boolean;
error?: string;
}
export function useChunkLoadingMetrics() {
const [metrics, setMetrics] = useState<ChunkMetrics[]>([]);
useEffect(() => {
const originalImport = window.import;
// Override dynamic import to track loading metrics
window.import = async function(specifier: string) {
const startTime = performance.now();
const chunkName = specifier.split('/').pop() || specifier;
try {
const module = await originalImport(specifier);
const loadTime = performance.now() - startTime;
setMetrics(prev => [...prev, {
chunkName,
loadTime,
success: true,
}]);
return module;
} catch (error) {
const loadTime = performance.now() - startTime;
setMetrics(prev => [...prev, {
chunkName,
loadTime,
success: false,
error: error.message,
}]);
throw error;
}
};
return () => {
window.import = originalImport;
};
}, []);
const getAverageLoadTime = () => {
const successfulLoads = metrics.filter(m => m.success);
if (successfulLoads.length === 0) return 0;
return successfulLoads.reduce((sum, m) => sum + m.loadTime, 0) / successfulLoads.length;
};
const getFailureRate = () => {
if (metrics.length === 0) return 0;
return (metrics.filter(m => !m.success).length / metrics.length) * 100;
};
return {
metrics,
averageLoadTime: getAverageLoadTime(),
failureRate: getFailureRate(),
totalChunksLoaded: metrics.length,
};
}
// Development component for monitoring
export function ChunkMetricsDisplay() {
const { metrics, averageLoadTime, failureRate, totalChunksLoaded } = useChunkLoadingMetrics();
if (process.env.NODE_ENV !== 'development') {
return null;
}
return (
<div className="fixed top-4 right-4 bg-black text-white p-4 rounded text-xs max-w-xs">
<h3 className="font-bold mb-2">Chunk Metrics</h3>
<div>Total chunks: {totalChunksLoaded}</div>
<div>Avg load time: {averageLoadTime.toFixed(2)}ms</div>
<div>Failure rate: {failureRate.toFixed(1)}%</div>
{metrics.length > 0 && (
<details className="mt-2">
<summary className="cursor-pointer">Recent chunks</summary>
<div className="mt-1 max-h-32 overflow-y-auto">
{metrics.slice(-5).map((metric, i) => (
<div key={i} className={`text-xs ${metric.success ? 'text-green-300' : 'text-red-300'}`}>
{metric.chunkName}: {metric.loadTime.toFixed(1)}ms
</div>
))}
</div>
</details>
)}
</div>
);
}
Common Pitfalls and Solutions
Over-Splitting Anti-Pattern
// ❌ DON'T: Over-split small components
const TinyButton = lazy(() => import('./TinyButton')); // 2KB component
const SmallIcon = lazy(() => import('./SmallIcon')); // 1KB component
// âś… DO: Group related small components
const UIComponents = lazy(() => import('./UIComponents')); // Contains TinyButton, SmallIcon, etc.
// ❌ DON'T: Split commonly used components
const Button = lazy(() => import('./Button')); // Used on every page
// âś… DO: Keep commonly used components in main bundle
import Button from './Button'; // Bundle with main app
Suspense Boundary Optimization
// ❌ DON'T: Wrap every lazy component individually
function BadExample() {
return (
<div>
<Suspense fallback={<Loading />}>
<LazyComponentA />
</Suspense>
<Suspense fallback={<Loading />}>
<LazyComponentB />
</Suspense>
<Suspense fallback={<Loading />}>
<LazyComponentC />
</Suspense>
</div>
);
}
// âś… DO: Use strategic Suspense boundaries
function GoodExample() {
return (
<div>
{/* Group related components under single Suspense */}
<Suspense fallback={<LoadingSection />}>
<LazyComponentA />
<LazyComponentB />
<LazyComponentC />
</Suspense>
</div>
);
}
The key to successful code splitting is being strategic about where and how you split your code. Focus on major route boundaries first, then move to large components and rarely-used features. Always measure the impact of your splits and adjust based on real-world usage patterns.
Remember: the goal isn't to have the most chunks, but to have the right chunks that improve your users' experience.