MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

React Code Splitting with React.lazy and Suspense

Implement code splitting: route-based splitting, component lazy loading, and prefetching.

ReactCode SplittingPerformanceFrontend

By MinhVo

Introduction

Modern web applications have grown increasingly complex, often bundling hundreds of components, libraries, and assets into a single monolithic JavaScript file. While this approach simplifies development, it creates a significant performance bottleneck: users must download the entire application bundle before they can interact with any part of it. This is where code splitting becomes an essential optimization technique for React applications.

Code splitting is the practice of breaking your application's JavaScript bundle into smaller, more manageable chunks that can be loaded on demand. Instead of forcing users to download everything upfront, you can split your code so that only the code needed for the current view is loaded initially. Additional chunks are fetched as the user navigates to different parts of the application or interacts with specific features.

React provides built-in support for code splitting through React.lazy and Suspense, making it easier than ever to implement this performance optimization. In this comprehensive guide, we'll explore how to effectively use these APIs, understand the underlying concepts, and implement production-ready code splitting strategies that will dramatically improve your application's loading performance.

Code splitting architecture diagram

Understanding Code Splitting: Core Concepts

Code splitting leverages dynamic imports (import()) to create split points in your application. When the bundler encounters a dynamic import, it creates a separate chunk for that module. This chunk is only loaded when the import is actually executed at runtime. The bundler analyzes your code and creates a dependency graph, and when you use dynamic imports, it marks those modules as potential split points.

The Problem with Large Bundles

When you build a React application using tools like Webpack, Rollup, or Vite, all your JavaScript code is typically combined into one or a few bundle files. As your application grows, these bundles can become several megabytes in size, causing slow initial load times, wasted bandwidth for unused features, poor Core Web Vitals scores (FCP, LCP), and significant mobile performance issues on slower connections.

Types of Code Splitting

There are several strategies for implementing code splitting in React applications:

Route-Based Splitting splits your application by routes, loading only the code needed for the current page. This is the most common and effective approach since users typically visit only a fraction of available routes.

Component-Based Splitting splits individual components that are large or rarely used, such as modals, tooltips, complex data visualizations, or rich text editors that users may never open.

Library-Based Splitting separates large third-party libraries into their own chunks, especially those only used in specific parts of your application, like charting libraries or PDF generators.

Feature-Based Splitting splits code by feature flags or user roles, loading different code for different user segments, enabling A/B testing and role-based dashboards.

How React.lazy Works Internally

React.lazy wraps a dynamic import and returns a new component that React can render. Under the hood, it uses a Promise-based mechanism: when the component is first rendered, React checks if the module has been loaded. If not, it suspends the component tree and renders the nearest Suspense boundary's fallback. Once the module loads, React stores the component reference and renders it normally on subsequent renders.

// Static import - bundled into main file
import { HeavyComponent } from './HeavyComponent';
 
// Dynamic import - creates a separate chunk
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

Module bundling visualization

Architecture and Design Patterns

React.lazy API Deep Dive

React.lazy takes a function that must return a Promise resolving to a module with a default export. It returns a component that can be rendered like any other React component, but it suspends rendering until the underlying module is loaded.

import React, { Suspense } from 'react';
 
// Lazy-loaded route components
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));
const Profile = React.lazy(() => import('./pages/Profile'));
const Analytics = React.lazy(() => import('./pages/Analytics'));
 
function App() {
  return (
    <div className="app">
      <nav>
        <Link to="/dashboard">Dashboard</Link>
        <Link to="/settings">Settings</Link>
        <Link to="/profile">Profile</Link>
      </nav>
      
      <Suspense fallback={<div>Loading...</div>}>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings" element={<Settings />} />
          <Route path="/profile" element={<Profile />} />
          <Route path="/analytics" element={<Analytics />} />
        </Routes>
      </Suspense>
    </div>
  );
}

Suspense Boundaries Strategy

Suspense lets you specify a loading indicator for a part of the component tree that is waiting for some asynchronous operation to complete. Key characteristics include: it can wrap multiple lazy components, supports nested boundaries for granular loading states, works with any async operation beyond code splitting, and provides consistent loading UX across your application.

Error Boundaries with Lazy Loading

When using React.lazy, network failures or chunk loading errors can prevent a lazy component from loading. Error boundaries catch these errors and display a fallback UI with recovery options.

import React, { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
 
const LazyComponent = React.lazy(() => import('./LazyComponent'));
 
function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert">
      <h2>Something went wrong:</h2>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}
 
function App() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => {}}>
      <Suspense fallback={<div>Loading...</div>}>
        <LazyComponent />
      </Suspense>
    </ErrorBoundary>
  );
}

Chunk Naming and Organization

When using dynamic imports, you can provide chunk names using webpack magic comments for better debugging and cache management:

// Webpack chunk naming
const Dashboard = React.lazy(() => import(
  /* webpackChunkName: "dashboard" */
  /* webpackPrefetch: true */
  './Dashboard'
));
 
// Group related chunks
const AdminPanel = React.lazy(() => import(
  /* webpackChunkName: "admin" */
  './admin/Panel'
));

Step-by-Step Implementation

Step 1: Setting Up Route-Based Code Splitting

The most effective code splitting strategy is route-based. Here's a complete implementation with React Router:

import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import LoadingSpinner from './components/LoadingSpinner';
import ErrorBoundary from './components/ErrorBoundary';
import Layout from './components/Layout';
 
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));
const UserProfile = lazy(() => import('./pages/UserProfile'));
const NotFound = lazy(() => import('./pages/NotFound'));
 
function App() {
  return (
    <BrowserRouter>
      <ErrorBoundary>
        <Layout>
          <Suspense fallback={<LoadingSpinner />}>
            <Routes>
              <Route path="/" element={<Home />} />
              <Route path="/dashboard" element={<Dashboard />} />
              <Route path="/analytics" element={<Analytics />} />
              <Route path="/settings" element={<Settings />} />
              <Route path="/profile/:userId" element={<UserProfile />} />
              <Route path="*" element={<NotFound />} />
            </Routes>
          </Suspense>
        </Layout>
      </ErrorBoundary>
    </BrowserRouter>
  );
}
 
export default App;

Step 2: Creating a Reusable Lazy Loading Wrapper

Build a utility function that simplifies lazy loading with built-in error handling and retry logic:

import React, { Suspense, lazy, ComponentType } from 'react';
 
interface LazyLoadOptions {
  fallback?: React.ReactNode;
  retries?: number;
}
 
function lazyLoad(
  importFunc: () => Promise<{ default: ComponentType<any> }>,
  options: LazyLoadOptions = {}
) {
  const { fallback = <LoadingSpinner />, retries = 3 } = options;
 
  const LazyComponent = lazy(() => {
    let lastError: Error;
    const attempt = (remaining: number): Promise<{ default: ComponentType<any> }> => {
      return importFunc().catch((error) => {
        lastError = error;
        if (remaining > 0) {
          const delay = Math.pow(2, retries - remaining) * 1000;
          return new Promise((resolve) => {
            setTimeout(() => resolve(attempt(remaining - 1)), delay);
          });
        }
        return Promise.reject(lastError);
      });
    };
    return attempt(retries);
  });
 
  return function LazyLoadedComponent(props: any) {
    return (
      <ErrorBoundary>
        <Suspense fallback={fallback}>
          <LazyComponent {...props} />
        </Suspense>
      </ErrorBoundary>
    );
  };
}
 
export default lazyLoad;

Step 3: Component-Level Code Splitting

For large components not tied to routes, implement component-level splitting with conditional loading:

import React, { Suspense, lazy, useState } from 'react';
 
const RichTextEditor = lazy(() => import('./RichTextEditor'));
const DataVisualization = lazy(() => import('./DataVisualization'));
const VideoPlayer = lazy(() => import('./VideoPlayer'));
 
function ArticleEditor() {
  const [showEditor, setShowEditor] = useState(false);
  const [showChart, setShowChart] = useState(false);
 
  return (
    <div className="article-editor">
      <h1>Article Editor</h1>
      <button onClick={() => setShowEditor(true)}>Open Rich Text Editor</button>
      {showEditor && (
        <Suspense fallback={<div>Loading editor...</div>}>
          <RichTextEditor />
        </Suspense>
      )}
      <button onClick={() => setShowChart(true)}>Show Data Visualization</button>
      {showChart && (
        <Suspense fallback={<div>Loading chart...</div>}>
          <DataVisualization data={[]} />
        </Suspense>
      )}
    </div>
  );
}

Step 4: Implementing Prefetching

Prefetching loads chunks before the user actually needs them, creating seamless navigation:

import React, { useCallback } from 'react';
import { Link } from 'react-router-dom';
 
function usePrefetch(importFunc: () => Promise<any>) {
  return useCallback(() => {
    importFunc();
  }, [importFunc]);
}
 
function NavigationLink({ to, children, importFunc }) {
  const prefetch = usePrefetch(importFunc);
  return (
    <Link to={to} onMouseEnter={prefetch} onFocus={prefetch}>
      {children}
    </Link>
  );
}
 
function Navigation() {
  return (
    <nav>
      <NavigationLink to="/dashboard" importFunc={() => import('./pages/Dashboard')}>
        Dashboard
      </NavigationLink>
      <NavigationLink to="/analytics" importFunc={() => import('./pages/Analytics')}>
        Analytics
      </NavigationLink>
    </nav>
  );
}

Step 5: Background Prefetching with requestIdleCallback

Prefetch chunks during idle time for optimal performance without impacting current interactions:

import { useEffect } from 'react';
 
function useIdlePrefetch(imports: Array<() => Promise<any>>) {
  useEffect(() => {
    if ('requestIdleCallback' in window) {
      const handle = requestIdleCallback(() => {
        imports.forEach((importFunc) => importFunc());
      });
      return () => cancelIdleCallback(handle);
    } else {
      const timeout = setTimeout(() => {
        imports.forEach((importFunc) => importFunc());
      }, 2000);
      return () => clearTimeout(timeout);
    }
  }, [imports]);
}
 
function App() {
  useIdlePrefetch([
    () => import('./pages/Dashboard'),
    () => import('./pages/Analytics'),
    () => import('./pages/Settings'),
  ]);
  return <Router>{/* routes */}</Router>;
}

Prefetching workflow

Real-World Use Cases and Case Studies

Use Case 1: E-Commerce Platform

An e-commerce application with product listings, user accounts, and admin dashboards benefits enormously from code splitting. Users browsing products don't need the admin dashboard code, and checkout flows can be split from product browsing:

const ProductListing = lazy(() => import('./pages/ProductListing'));
const ProductDetail = lazy(() => import('./pages/ProductDetail'));
const ShoppingCart = lazy(() => import('./pages/ShoppingCart'));
const Checkout = lazy(() => import('./pages/Checkout'));
const OrderHistory = lazy(() => import('./pages/OrderHistory'));
const AdminDashboard = lazy(() => import('./pages/admin/Dashboard'));
const AdminInventory = lazy(() => import('./pages/admin/Inventory'));
 
// Component splitting for heavy features
const ProductRecommendations = lazy(() => import('./components/ProductRecommendations'));
const SizeGuide = lazy(() => import('./components/SizeGuide'));
const ReviewSection = lazy(() => import('./components/ReviewSection'));

Use Case 2: Dashboard Application

Complex dashboards with multiple visualization types can split by chart type, loading only the visualization the user selects:

const LineChart = lazy(() => import('./charts/LineChart'));
const BarChart = lazy(() => import('./charts/BarChart'));
const PieChart = lazy(() => import('./charts/PieChart'));
const HeatMap = lazy(() => import('./charts/HeatMap'));
const DataGrid = lazy(() => import('./components/DataGrid'));
 
function Dashboard() {
  const [chartType, setChartType] = useState('line');
  const ChartComponent = { line: LineChart, bar: BarChart, pie: PieChart, heatmap: HeatMap }[chartType];
 
  return (
    <div>
      <select onChange={(e) => setChartType(e.target.value)}>
        <option value="line">Line Chart</option>
        <option value="bar">Bar Chart</option>
        <option value="pie">Pie Chart</option>
        <option value="heatmap">Heat Map</option>
      </select>
      <Suspense fallback={<ChartSkeleton />}>
        <ChartComponent data={data} />
      </Suspense>
    </div>
  );
}

Use Case 3: Content Management System

A CMS with rich editing capabilities splits heavy editor components that aren't always needed:

const MarkdownEditor = lazy(() => import('./editors/MarkdownEditor'));
const RichTextEditor = lazy(() => import('./editors/RichTextEditor'));
const MediaLibrary = lazy(() => import('./media/MediaLibrary'));
const ImageCropper = lazy(() => import('./media/ImageCropper'));
const ContentPreview = lazy(() => import('./preview/ContentPreview'));
const SEOAnalyzer = lazy(() => import('./tools/SEOAnalyzer'));

Use Case 4: Social Media Platform

Social applications with feeds, messaging, and media sharing benefit from feature-based splitting where users may never access all features:

const Feed = lazy(() => import('./features/Feed'));
const Messaging = lazy(() => import('./features/Messaging'));
const Stories = lazy(() => import('./features/Stories'));
const LiveStream = lazy(() => import('./features/LiveStream'));
const PhotoEditor = lazy(() => import('./features/PhotoEditor'));

Best Practices for Production

  1. Implement Loading Skeletons: Instead of generic spinners, use skeleton screens that match the layout of the component being loaded. This reduces perceived loading time and prevents layout shifts.

  2. Prefetch on Hover: Load chunks when users hover over navigation links, giving the chunk time to load before they actually click. This creates near-instant navigation.

  3. Handle Loading Errors Gracefully: Implement retry logic with exponential backoff for chunk loading failures. Network issues shouldn't crash your application.

  4. Avoid Over-Splitting: Don't split tiny components like individual buttons or inputs. The HTTP request overhead outweighs the benefits. Split at feature or route level.

  5. Use Consistent Chunk Naming: Organize chunks with consistent naming conventions like feature-component.[hash].js for better debugging and cache management.

  6. Monitor Chunk Sizes: Use bundle analysis tools to identify large chunks. Aim for 100KB-300KB uncompressed for optimal loading performance.

  7. Implement Progressive Loading: Load critical UI first, then enhance with additional features using nested Suspense boundaries.

  8. Leverage Background Prefetching: Use requestIdleCallback to prefetch non-critical chunks during idle time without impacting current user interactions.

Common Pitfalls and Solutions

PitfallImpactSolution
No error boundaries around lazy componentsApp crashes on chunk load failureWrap all lazy components in ErrorBoundary with retry logic
Generic loading spinnersPoor perceived performance and layout shiftsUse skeleton screens matching component layout
No prefetching strategySlow navigation between routesImplement hover-based or idle-time prefetching
Over-splitting small componentsIncreased HTTP requests and overheadSplit at feature or route level, not individual components
Forgetting Suspense wrapperRuntime error: "A component suspended while rendering..."Always wrap lazy components in Suspense boundaries
Static analysis breaksTree-shaking fails, bundles growEnsure dynamic imports use variable expressions

Performance Optimization

Measuring Code Splitting Impact

Use Lighthouse and Web Vitals to measure the impact of your code splitting strategy:

function trackChunkLoad(chunkName: string, startTime: number) {
  const duration = performance.now() - startTime;
  gtag('event', 'chunk_load', {
    chunk_name: chunkName,
    duration_ms: Math.round(duration),
  });
}
 
const Dashboard = lazy(() => {
  const start = performance.now();
  return import('./Dashboard').then((module) => {
    trackChunkLoad('dashboard', start);
    return module;
  });
});

Optimal Chunk Configuration

Configure your bundler to create optimally-sized chunks:

// Webpack configuration
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      maxSize: 300000,
      minSize: 100000,
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
        },
      },
    },
  },
};

Compression and Delivery

Ensure your server compresses chunks with Brotli or gzip:

gzip on;
gzip_types application/javascript text/css;
gzip_min_length 1000;
brotli on;
brotli_types application/javascript text/css;

Comparison with Alternatives

FeatureReact.lazy + SuspenseLoadable ComponentsReact Server Components
SSR SupportLimited (React 18+)FullNative
Server ComponentsNativePlugin requiredNative
Error HandlingErrorBoundaryBuilt-inErrorBoundary
PrefetchingManualPluginAutomatic
Bundle Size0KB~3KB0KB
TypeScript SupportNativeTypes availableNative
Streaming SupportReact 18+LimitedReact 18+

Advanced Patterns

Route Splitting with Data Preloading

Combine code splitting with data loading for optimal UX by fetching data while the chunk loads:

import { lazy, Suspense } from 'react';
import { useQuery } from '@tanstack/react-query';
 
const Dashboard = lazy(() => import('./Dashboard'));
 
function DashboardPage() {
  const { data, isLoading } = useQuery({
    queryKey: ['dashboard'],
    queryFn: fetchDashboardData,
    staleTime: 5 * 60 * 1000,
  });
 
  return (
    <Suspense fallback={<DashboardSkeleton />}>
      <Dashboard data={data} isLoading={isLoading} />
    </Suspense>
  );
}

Shared Chunk Optimization

Extract shared dependencies into common chunks to avoid duplication:

// Webpack splitChunks configuration
optimization: {
  splitChunks: {
    chunks: 'all',
    cacheGroups: {
      react: {
        test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
        name: 'react-vendor',
        chunks: 'all',
        priority: 20,
      },
      ui: {
        test: /[\\/]node_modules[\\/](@mui|@chakra)[\\/]/,
        name: 'ui-vendor',
        chunks: 'all',
        priority: 10,
      },
    },
  },
}

Micro-Frontend Code Splitting

For micro-frontend architectures, split at the application boundary:

const App1 = lazy(() => import('app1/Module'));
const App2 = lazy(() => import('app2/Module'));
 
function Shell() {
  return (
    <Suspense fallback={<ShellLoading />}>
      <Routes>
        <Route path="/app1/*" element={<App1 />} />
        <Route path="/app2/*" element={<App2 />} />
      </Routes>
    </Suspense>
  );
}

Testing Strategies

Unit Testing Lazy Components

import { render, screen, waitFor } from '@testing-library/react';
 
jest.mock('./Dashboard', () => ({
  __esModule: true,
  default: () => <div>Dashboard Content</div>,
}));
 
describe('Dashboard Lazy Loading', () => {
  it('renders loading state then content', async () => {
    const Dashboard = React.lazy(() => import('./Dashboard'));
    render(
      <ErrorBoundary>
        <Suspense fallback={<div>Loading...</div>}>
          <Dashboard />
        </Suspense>
      </ErrorBoundary>
    );
 
    expect(screen.getByText('Loading...')).toBeInTheDocument();
    await waitFor(() => {
      expect(screen.getByText('Dashboard Content')).toBeInTheDocument();
    });
  });
});

Integration Testing Navigation Flow

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
 
describe('Code Splitting Integration', () => {
  it('loads chunks on navigation', async () => {
    render(<BrowserRouter><App /></BrowserRouter>);
    fireEvent.click(screen.getByText('Dashboard'));
    expect(screen.getByText('Loading...')).toBeInTheDocument();
    await waitFor(() => {
      expect(screen.getByText('Dashboard Content')).toBeInTheDocument();
    });
  });
});

Future Outlook

React Server Components and Code Splitting

Server Components change how we think about code splitting by allowing components to run on the server, reducing client JavaScript:

// Server Component - zero client JS
async function ServerDashboard() {
  const data = await fetchDashboardData();
  return <DashboardView data={data} />;
}
 
// Client Component - only sent when needed
'use client';
const InteractiveChart = lazy(() => import('./InteractiveChart'));

Streaming SSR with Code Splitting

React 18's streaming SSR works seamlessly with code splitting, sending HTML chunks as they become available while lazy components load in parallel.

Module Federation

Webpack 5's Module Federation enables sharing code between separate applications at runtime, taking code splitting to the architectural level:

new ModuleFederationPlugin({
  name: 'app1',
  remotes: { app2: 'app2@http://localhost:3001/remoteEntry.js' },
  shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
});

Conclusion

Code splitting with React.lazy and Suspense is a fundamental optimization technique that every React developer should master. By splitting your application into smaller, on-demand chunks, you can dramatically improve initial load times, reduce bandwidth usage, and provide a better user experience.

Key takeaways:

  1. Start with route-based splitting for the biggest impact with minimal complexity
  2. Always use ErrorBoundaries to handle chunk loading failures gracefully
  3. Implement prefetching to load chunks before users need them
  4. Use loading skeletons that match the layout of the component being loaded
  5. Monitor chunk sizes and aim for 100KB-300KB for optimal performance
  6. Avoid over-splitting tiny components; split at feature or route level
  7. Test lazy loading by mocking imports and verifying loading states
  8. Leverage Server Components for components that don't need client-side interactivity

As React evolves with Server Components, streaming SSR, and new bundler features, code splitting remains a crucial optimization strategy. The patterns in this guide will help you build faster, more efficient React applications.