My React app was 2.5MB. Users on slow connections waited 15 seconds to see anything. Time to Interactive was abysmal. Google Lighthouse scores were red across the board.
I knew I needed code splitting. I'd heard about React.lazy() and dynamic imports. But every tutorial showed JavaScript examples, and when I tried to add TypeScript, nothing worked:
// ❌ This breaks everything
const Dashboard = React.lazy(() => import('./Dashboard'));
// Type error: Cannot find module './Dashboard' or its corresponding type declarations
The types didn't import correctly. Suspense boundaries felt clunky. Preloading was a mystery. And I had no idea if my splitting strategy actually worked.
After months of optimization, I got my initial bundle down to 180KB. Time to Interactive dropped from 15 seconds to 2 seconds. Here's everything I learned about code splitting with TypeScript.
The Fundamentals: How Code Splitting Works
Before diving into TypeScript, understand what's happening:
Without code splitting:
app.js (2.5MB) → User waits for entire app before seeing anything
With code splitting:
main.js (180KB) → User sees app immediately
dashboard.js (400KB) → Loads when user navigates to dashboard
admin.js (600KB) → Loads only for admin users
charts.js (350KB) → Loads when user opens charts
Code splitting transforms one massive bundle into smaller chunks that load on-demand.
Pattern 1: Basic Type-Safe Lazy Loading
The Problem with Simple Lazy Loading
// ❌ Type inference breaks
const Dashboard = React.lazy(() => import('./Dashboard'));
// Later...
<Dashboard /> // No prop autocomplete, no type checking
The Solution: Proper Component Types
// Dashboard.tsx
interface DashboardProps {
userId: string;
onLogout: () => void;
}
export default function Dashboard({ userId, onLogout }: DashboardProps) {
return <div>Dashboard for {userId}</div>;
}
// App.tsx
import { lazy, ComponentType } from 'react';
// ✅ Explicitly type the lazy component
const Dashboard = lazy<ComponentType<DashboardProps>>(
() => import('./Dashboard')
);
// Usage - full type safety!
function App() {
return (
<Suspense fallback={<Loading />}>
<Dashboard
userId="123"
onLogout={handleLogout}
// TypeScript knows the props!
/>
</Suspense>
);
}
Generic Lazy Loader Helper
// utils/lazy.ts
import { lazy, ComponentType } from 'react';
/**
* Type-safe wrapper around React.lazy
*/
export function lazyLoad<T extends ComponentType<any>>(
factory: () => Promise<{ default: T }>
): T {
return lazy(factory) as unknown as T;
}
// Usage
const Dashboard = lazyLoad<ComponentType<DashboardProps>>(
() => import('./Dashboard')
);
// Even cleaner - infer from import
const Settings = lazyLoad(() => import('./Settings'));
// TypeScript infers the component type!
Pattern 2: Route-Based Code Splitting
Split by routes for maximum impact.
Basic Route Splitting
// routes.tsx
import { lazy } from 'react';
import { RouteObject } from 'react-router-dom';
// Lazy load route components
const HomePage = lazy(() => import('./pages/Home'));
const DashboardPage = lazy(() => import('./pages/Dashboard'));
const ProfilePage = lazy(() => import('./pages/Profile'));
const SettingsPage = lazy(() => import('./pages/Settings'));
export const routes: RouteObject[] = [
{
path: '/',
element: <HomePage />,
},
{
path: '/dashboard',
element: <DashboardPage />,
},
{
path: '/profile',
element: <ProfilePage />,
},
{
path: '/settings',
element: <SettingsPage />,
},
];
Suspense Wrapper for Routes
// components/RouteWithSuspense.tsx
import { Suspense, ComponentType } from 'react';
interface RouteWithSuspenseProps {
component: ComponentType;
fallback?: React.ReactNode;
}
export function RouteWithSuspense({
component: Component,
fallback = <PageLoader />
}: RouteWithSuspenseProps) {
return (
<Suspense fallback={fallback}>
<Component />
</Suspense>
);
}
// Usage
const routes: RouteObject[] = [
{
path: '/dashboard',
element: <RouteWithSuspense component={DashboardPage} />,
},
];
Nested Route Splitting
// App.tsx
import { Outlet } from 'react-router-dom';
const AdminLayout = lazy(() => import('./layouts/AdminLayout'));
const UserManagement = lazy(() => import('./pages/admin/UserManagement'));
const Analytics = lazy(() => import('./pages/admin/Analytics'));
const Reports = lazy(() => import('./pages/admin/Reports'));
export const routes: RouteObject[] = [
{
path: '/admin',
element: (
<Suspense fallback={<LayoutSkeleton />}>
<AdminLayout />
</Suspense>
),
children: [
{
path: 'users',
element: (
<Suspense fallback={<PageSkeleton />}>
<UserManagement />
</Suspense>
),
},
{
path: 'analytics',
element: (
<Suspense fallback={<PageSkeleton />}>
<Analytics />
</Suspense>
),
},
{
path: 'reports',
element: (
<Suspense fallback={<PageSkeleton />}>
<Reports />
</Suspense>
),
},
],
},
];
Pattern 3: Component-Based Code Splitting
Split heavy components, not routes.
Identifying Split Candidates
// Heavy components to split:
// - Rich text editors
// - Chart libraries
// - Data tables
// - Modals (especially heavy ones)
// - Admin panels
// - Feature-flagged components
// Dashboard.tsx
import { lazy, useState } from 'react';
// ✅ Split heavy chart component
const ChartComponent = lazy(() => import('./ChartComponent'));
// ✅ Split heavy data table
const DataTable = lazy(() => import('./DataTable'));
export function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<h1>Dashboard</h1>
<button onClick={() => setShowChart(true)}>
Show Chart
</button>
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<ChartComponent data={chartData} />
</Suspense>
)}
<Suspense fallback={<TableSkeleton />}>
<DataTable />
</Suspense>
</div>
);
}
Modal Code Splitting
// Modals are perfect for code splitting - they're not needed until opened
interface UserModalProps {
userId: string;
onClose: () => void;
}
const UserModal = lazy(() => import('./modals/UserModal'));
function UserList() {
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
return (
<div>
{users.map(user => (
<button key={user.id} onClick={() => setSelectedUserId(user.id)}>
{user.name}
</button>
))}
{selectedUserId && (
<Suspense fallback={<ModalSkeleton />}>
<UserModal
userId={selectedUserId}
onClose={() => setSelectedUserId(null)}
/>
</Suspense>
)}
</div>
);
}
Pattern 4: Library Code Splitting
Split large dependencies dynamically.
Dynamic Library Imports
// utils/dynamicImports.ts
/**
* Lazy load Chart.js only when needed
*/
export async function loadChartLibrary() {
const { Chart, registerables } = await import('chart.js');
Chart.register(...registerables);
return Chart;
}
/**
* Lazy load date-fns only when needed
*/
export async function formatDate(date: Date, format: string) {
const { format: formatFn } = await import('date-fns');
return formatFn(date, format);
}
/**
* Lazy load lodash functions
*/
export async function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): Promise<T & { cancel: () => void }> {
const { default: debounce } = await import('lodash/debounce');
return debounce(func, wait);
}
// Usage
function ChartComponent() {
const [Chart, setChart] = useState<typeof import('chart.js').Chart | null>(null);
useEffect(() => {
loadChartLibrary().then(setChart);
}, []);
if (!Chart) return <div>Loading chart...</div>;
return <canvas ref={canvasRef} />;
}
Code Splitting Heavy Utilities
// utils/markdown.ts
/**
* Don't import markdown library at app startup
* Load it only when rendering markdown
*/
export async function renderMarkdown(markdown: string): Promise<string> {
const { marked } = await import('marked');
return marked(markdown);
}
// Usage
function MarkdownPreview({ content }: { content: string }) {
const [html, setHtml] = useState<string>('');
useEffect(() => {
renderMarkdown(content).then(setHtml);
}, [content]);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
Pattern 5: Preloading Strategies
Load code before it's needed.
Preload on Hover
// components/Link.tsx
import { Link as RouterLink, LinkProps } from 'react-router-dom';
import { ComponentType } from 'react';
interface PreloadableLinkProps extends LinkProps {
preload?: () => Promise<{ default: ComponentType }>;
}
export function Link({ preload, ...props }: PreloadableLinkProps) {
const handleMouseEnter = () => {
if (preload) {
// Start loading when user hovers
preload();
}
};
return (
<RouterLink
{...props}
onMouseEnter={handleMouseEnter}
onFocus={handleMouseEnter}
/>
);
}
// Usage
<Link
to="/dashboard"
preload={() => import('./pages/Dashboard')}
>
Go to Dashboard
</Link>
Preload on Idle
// utils/preload.ts
/**
* Preload component during browser idle time
*/
export function preloadOnIdle(
factory: () => Promise<{ default: ComponentType<any> }>
): void {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
factory();
});
} else {
// Fallback for browsers without requestIdleCallback
setTimeout(() => {
factory();
}, 1);
}
}
// App.tsx
useEffect(() => {
// Preload likely next pages during idle time
preloadOnIdle(() => import('./pages/Dashboard'));
preloadOnIdle(() => import('./pages/Profile'));
}, []);
Preload on Route Match
// hooks/usePreloadRoutes.ts
import { useLocation } from 'react-router-dom';
import { useEffect } from 'react';
interface RoutePreloadMap {
[path: string]: () => Promise<{ default: ComponentType<any> }>;
}
const preloadMap: RoutePreloadMap = {
'/': () => import('./pages/Dashboard'),
'/profile': () => import('./pages/Settings'),
'/dashboard': () => import('./pages/Analytics'),
};
export function usePreloadRoutes() {
const location = useLocation();
useEffect(() => {
// Preload likely next route based on current route
const nextRoute = preloadMap[location.pathname];
if (nextRoute) {
preloadOnIdle(nextRoute);
}
}, [location.pathname]);
}
Aggressive Preloading
// Preload everything after initial render
function App() {
useEffect(() => {
// Wait for initial render to complete
setTimeout(() => {
// Preload all routes
import('./pages/Dashboard');
import('./pages/Profile');
import('./pages/Settings');
import('./pages/Admin');
}, 2000);
}, []);
return <Router />;
}
Pattern 6: Type-Safe Suspense Boundaries
Nested Suspense Boundaries
// Different loading states for different parts
function App() {
return (
<Suspense fallback={<AppShell />}>
<Layout>
<Suspense fallback={<Sidebar.Skeleton />}>
<Sidebar />
</Suspense>
<Main>
<Suspense fallback={<PageLoader />}>
<Routes />
</Suspense>
</Main>
</Layout>
</Suspense>
);
}
Error Boundaries with Suspense
// ErrorBoundary.tsx
import { Component, ReactNode } from 'react';
interface Props {
fallback: (error: Error) => ReactNode;
children: ReactNode;
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
render() {
if (this.state.error) {
return this.props.fallback(this.state.error);
}
return this.props.children;
}
}
// Usage - wrap Suspense with ErrorBoundary
function App() {
return (
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<Suspense fallback={<Loading />}>
<Dashboard />
</Suspense>
</ErrorBoundary>
);
}
Custom Suspense Component
// components/SuspenseWithError.tsx
import { Suspense, ReactNode } from 'react';
import { ErrorBoundary } from './ErrorBoundary';
interface SuspenseWithErrorProps {
children: ReactNode;
fallback: ReactNode;
errorFallback?: (error: Error) => ReactNode;
}
export function SuspenseWithError({
children,
fallback,
errorFallback = (error) => <div>Error: {error.message}</div>,
}: SuspenseWithErrorProps) {
return (
<ErrorBoundary fallback={errorFallback}>
<Suspense fallback={fallback}>
{children}
</Suspense>
</ErrorBoundary>
);
}
// Usage
<SuspenseWithError
fallback={<PageLoader />}
errorFallback={(error) => <ErrorPage error={error} />}
>
<Dashboard />
</SuspenseWithError>
Pattern 7: Handling Shared Types
Type-Only Imports
// types/user.ts
export interface User {
id: string;
name: string;
email: string;
}
// Dashboard.tsx
// ✅ Type-only import - doesn't affect bundle
import type { User } from '../types/user';
export default function Dashboard({ user }: { user: User }) {
return <div>Hello {user.name}</div>;
}
Shared Type Definitions
// Create a types-only package for shared types
// This doesn't get bundled with code
// types/index.ts
export type { User } from './user';
export type { Post } from './post';
export type { Comment } from './comment';
// Components import types without bundling them
import type { User, Post } from '@/types';
Avoiding Type Duplication
// ❌ Bad - duplicates types
// Dashboard.tsx
interface User {
id: string;
name: string;
}
// Profile.tsx
interface User {
id: string;
name: string;
}
// ✅ Good - shared types
// types/user.ts
export interface User {
id: string;
name: string;
}
// Both import the same type
import type { User } from '@/types';
Pattern 8: Dynamic Import Utilities
Type-Safe Dynamic Component Loader
// utils/loadComponent.ts
import { ComponentType } from 'react';
interface LoadComponentOptions {
fallback?: React.ReactNode;
onError?: (error: Error) => void;
}
export function loadComponent<P = {}>(
factory: () => Promise<{ default: ComponentType<P> }>,
options: LoadComponentOptions = {}
): ComponentType<P> {
const LazyComponent = React.lazy(factory);
return function LoadedComponent(props: P) {
return (
<ErrorBoundary
fallback={(error) => {
options.onError?.(error);
return <div>Failed to load component</div>;
}}
>
<Suspense fallback={options.fallback ?? <div>Loading...</div>}>
<LazyComponent {...props} />
</Suspense>
</ErrorBoundary>
);
};
}
// Usage
const Dashboard = loadComponent(
() => import('./Dashboard'),
{
fallback: <DashboardSkeleton />,
onError: (error) => console.error('Failed to load dashboard', error),
}
);
Retry Logic for Failed Chunks
// utils/retryImport.ts
interface ImportRetryOptions {
maxRetries?: number;
delay?: number;
}
/**
* Retry dynamic imports that fail (common with network issues)
*/
export async function retryImport<T>(
factory: () => Promise<T>,
{ maxRetries = 3, delay = 1000 }: ImportRetryOptions = {}
): Promise<T> {
let lastError: Error;
for (let i = 0; i < maxRetries; i++) {
try {
return await factory();
} catch (error) {
lastError = error as Error;
// Wait before retrying
if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, delay * (i + 1)));
}
}
}
throw lastError!;
}
// Usage
const Dashboard = lazy(() =>
retryImport(() => import('./Dashboard'))
);
Prefetch Helper
// utils/prefetch.ts
/**
* Prefetch a component without rendering it
*/
export function prefetchComponent(
factory: () => Promise<{ default: ComponentType<any> }>
): void {
factory().catch(() => {
// Silently fail - prefetch is optimization, not critical
});
}
// Prefetch on user interaction
function Navigation() {
return (
<nav>
<a
href="/dashboard"
onMouseEnter={() => prefetchComponent(() => import('./pages/Dashboard'))}
>
Dashboard
</a>
</nav>
);
}
Pattern 9: Bundle Analysis and Optimization
Analyzing Bundle Size
// Install bundle analyzer
// npm install --save-dev webpack-bundle-analyzer
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
}),
],
build: {
rollupOptions: {
output: {
manualChunks: {
// Vendor splitting
'react-vendor': ['react', 'react-dom'],
'router': ['react-router-dom'],
'query': ['@tanstack/react-query'],
},
},
},
},
});
Manual Chunk Configuration
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
// Split node_modules into separate chunks
if (id.includes('node_modules')) {
if (id.includes('react') || id.includes('react-dom')) {
return 'react-vendor';
}
if (id.includes('lodash')) {
return 'lodash-vendor';
}
if (id.includes('chart')) {
return 'chart-vendor';
}
return 'vendor';
}
// Split pages into separate chunks
if (id.includes('/pages/')) {
const pageName = id.split('/pages/')[1].split('/')[0];
return `page-${pageName}`;
}
},
},
},
},
});
Pattern 10: Testing Lazy Components
Testing with Suspense
// Dashboard.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { Suspense } from 'react';
// Import actual component for testing, not lazy version
import Dashboard from './Dashboard';
test('renders dashboard', async () => {
render(
<Suspense fallback={<div>Loading...</div>}>
<Dashboard />
</Suspense>
);
// Wait for suspense to resolve
await waitFor(() => {
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});
});
Mock Lazy Imports
// __mocks__/lazyComponent.ts
import { ComponentType } from 'react';
export function mockLazy<T extends ComponentType<any>>(
component: T
): T {
return component;
}
// Dashboard.test.tsx
jest.mock('./Dashboard', () => ({
default: () => <div>Dashboard Mock</div>,
}));
test('renders mocked lazy component', () => {
const Dashboard = lazy(() => import('./Dashboard'));
render(
<Suspense fallback={<div>Loading...</div>}>
<Dashboard />
</Suspense>
);
expect(screen.getByText('Dashboard Mock')).toBeInTheDocument();
});
Real-World Example: Complete Implementation
// App.tsx - Complete code splitting setup
import { lazy, Suspense, useEffect } from 'react';
import { Routes, Route, useLocation } from 'react-router-dom';
import { ErrorBoundary } from './components/ErrorBoundary';
import { retryImport } from './utils/retryImport';
import { preloadOnIdle } from './utils/preload';
// Lazy load pages
const HomePage = lazy(() => retryImport(() => import('./pages/Home')));
const DashboardPage = lazy(() => retryImport(() => import('./pages/Dashboard')));
const ProfilePage = lazy(() => retryImport(() => import('./pages/Profile')));
const SettingsPage = lazy(() => retryImport(() => import('./pages/Settings')));
const AdminPage = lazy(() => retryImport(() => import('./pages/Admin')));
// Preload map
const preloadMap: Record<string, () => void> = {
'/': () => preloadOnIdle(() => import('./pages/Dashboard')),
'/dashboard': () => preloadOnIdle(() => import('./pages/Profile')),
};
function App() {
const location = useLocation();
// Preload next likely page
useEffect(() => {
const preload = preloadMap[location.pathname];
preload?.();
}, [location.pathname]);
return (
<ErrorBoundary
fallback={(error) => (
<div>
<h1>Application Error</h1>
<p>{error.message}</p>
<button onClick={() => window.location.reload()}>
Reload
</button>
</div>
)}
>
<Routes>
<Route
path="/"
element={
<Suspense fallback={<PageLoader />}>
<HomePage />
</Suspense>
}
/>
<Route
path="/dashboard"
element={
<Suspense fallback={<PageLoader />}>
<DashboardPage />
</Suspense>
}
/>
<Route
path="/profile"
element={
<Suspense fallback={<PageLoader />}>
<ProfilePage />
</Suspense>
}
/>
<Route
path="/settings"
element={
<Suspense fallback={<PageLoader />}>
<SettingsPage />
</Suspense>
}
/>
<Route
path="/admin"
element={
<Suspense fallback={<PageLoader />}>
<AdminPage />
</Suspense>
}
/>
</Routes>
</ErrorBoundary>
);
}
export default App;
Performance Metrics
Before Code Splitting
Initial Bundle: 2.5MB
Time to Interactive: 15s (3G)
Lighthouse Score: 23/100
First Contentful Paint: 8s
After Code Splitting
Initial Bundle: 180KB
Time to Interactive: 2s (3G)
Lighthouse Score: 94/100
First Contentful Paint: 1.2s
Route Chunks:
- Dashboard: 400KB
- Admin: 600KB
- Charts: 350KB
Best Practices
1. Split at Route Level First
Routes are natural split points. Users navigate between them.
2. Split Heavy Libraries
Chart.js, Markdown parsers, Rich text editors—split them all.
3. Don't Over-Split
Splitting every component creates overhead. Split things > 50KB.
4. Preload Aggressively
Preload on hover, idle time, or based on user patterns.
5. Monitor Bundle Sizes
Use bundle analyzer to find optimization opportunities.
6. Handle Errors Gracefully
Always wrap Suspense with ErrorBoundary.
7. Test Lazy Components
Test the actual components, not the lazy wrappers.
Common Mistakes
❌ Mistake 1: Splitting Too Small
// ❌ Bad - overhead exceeds benefit
const Button = lazy(() => import('./Button')); // 2KB component
❌ Mistake 2: No Error Handling
// ❌ Bad - crashes on failed chunk load
<Suspense fallback={<Loading />}>
<Dashboard />
</Suspense>
❌ Mistake 3: Blocking Renders
// ❌ Bad - entire app waits for dashboard
function App() {
const Dashboard = lazy(() => import('./Dashboard'));
return <Dashboard />;
}
❌ Mistake 4: Not Using Type-Only Imports
// ❌ Bad - bundles types with code
import { User } from './types';
// ✅ Good - types are stripped
import type { User } from './types';
Conclusion
Code splitting transforms your app from a monolith to a nimble, fast-loading experience.
The strategy:
- Split by routes first
- Split heavy components second
- Split libraries third
- Preload aggressively
- Monitor and optimize
The tools:
-
React.lazy()for components - Dynamic
import()for utilities - Bundle analyzer for visibility
- Preloading for perceived performance
The result:
- Faster initial load
- Better Time to Interactive
- Happier users
- Higher conversion rates
Start with routes. Measure with Lighthouse. Iterate based on data.
Your users on slow connections will thank you.
What's your bundle size? Run npm run build and share your before/after!
Top comments (0)