Code Splitting: Optimize Bundle Size with Dynamic Imports

Medium•

Code splitting is essential for large applications. Learn when and how to split your code for optimal performance.

Asked In

Quick Decision Guide

Quick Implementation Guide:

Route-based: Use React.lazy() with Suspense for page-level splitting. Each route becomes a separate chunk.

Component-based: Split heavy components (charts, editors, modals) that aren't immediately visible. Use dynamic imports: const Chart = lazy(() => import('./Chart'))

Vendor splitting: Configure bundler (Webpack/Vite) to separate node_modules automatically. Vendor code changes less frequently, improving cache hits.

Dynamic imports: Load modules conditionally: if (user.isPremium) await import('./PremiumFeature')

Best Practice: Start with route-based splitting, then component-based for heavy features. Monitor bundle sizes - split if initial bundle >100KB.

Route-Based Code Splitting

React Router Example

import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// Lazy load routes
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div>Loading...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/dashboard" element={<Dashboard />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

How It Works

1. User visits "/" → Only Home chunk loads

2. User navigates to "/about" → About chunk loads on-demand

3. Each route is a separate bundle

Bundle Structure

main.js (100KB)        → Core app code
home.chunk.js (50KB)   → Home page
about.chunk.js (60KB)  → About page
dashboard.chunk.js (80KB) → Dashboard page
vendor.js (200KB)      → Third-party libraries

Initial load: main.js + home.chunk.js = 150KB (instead of 490KB)

Component-Based Splitting

Lazy Load Heavy Components

import { lazy, Suspense, useState } from 'react';

// Heavy component loaded on-demand
const Chart = lazy(() => import('./Chart'));
const DataTable = lazy(() => import('./DataTable'));

function Dashboard() {
  const [showChart, setShowChart] = useState(false);
  
  return (
    <div>
      <button onClick={() => setShowChart(true)}>
        Show Chart
      </button>
      
      {showChart && (
        <Suspense fallback={<div>Loading chart...</div>}>
          <Chart />
        </Suspense>
      )}
    </div>
  );
}

When to Use

•Heavy components: Charts, editors, complex UI
•Conditional rendering: Only load when needed
•Below-the-fold content: Load when user scrolls

Example: Modal Component

const Modal = lazy(() => import('./Modal'));

function App() {
  const [showModal, setShowModal] = useState(false);
  
  return (
    <>
      <button onClick={() => setShowModal(true)}>Open Modal</button>
      {showModal && (
        <Suspense fallback={null}>
          <Modal onClose={() => setShowModal(false)} />
        </Suspense>
      )}
    </>
  );
}

Dynamic Imports

Native Dynamic Import

// Static import (bundled)
import utils from './utils';

// Dynamic import (code split)
const loadUtils = async () => {
  const utils = await import('./utils');
  return utils.default;
};

// Usage
const utils = await loadUtils();
utils.doSomething();

With Error Handling

async function loadFeature(featureName) {
  try {
    const module = await import('./features/' + featureName);
    return module.default;
  } catch (error) {
    console.error(`Failed to load ${featureName}:`, error);
    // Fallback
    return null;
  }
}

// Usage
const feature = await loadFeature('analytics');
if (feature) {
  feature.init();
}

Conditional Loading

// Load based on user permissions
if (user.isAdmin) {
  const AdminPanel = await import('./AdminPanel');
  render(<AdminPanel />);
}

// Load based on feature flags
if (featureFlags.newDashboard) {
  const NewDashboard = await import('./NewDashboard');
  return <NewDashboard />;
}

Vendor Splitting

Separate Vendor Code

Vendor code (node_modules) changes less frequently than app code.

Separating it improves caching.

Webpack Configuration

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
        },
      },
    },
  },
};

Result

Before:
  bundle.js (500KB) - Everything together

After:
  vendors.js (300KB) - Third-party code (rarely changes)
  app.js (200KB)     - Your code (changes frequently)

Benefit: When you update app code, users only re-download app.js, not vendors.js

Best Practices

When to Code Split

Do Split

✓Routes/pages
✓Heavy components (charts, editors)
✓Features behind feature flags
✓Admin-only features
✓Third-party libraries

Don't Split

✗Small utilities (< 5KB)
✗Critical above-the-fold code
✗Frequently used components
✗Shared dependencies

Optimization Tips

1. Preload Critical Chunks

<link rel="preload" href="/chunks/critical.js" as="script">

2. Prefetch Future Routes

// Prefetch on hover
<Link 
  to="/dashboard"
  onMouseEnter={() => import('./pages/Dashboard')}
>
  Dashboard
</Link>

3. Loading States

<Suspense fallback={<LoadingSpinner />}>
  <LazyComponent />
</Suspense>

4. Error Boundaries

<ErrorBoundary fallback={<ErrorPage />}>
  <Suspense fallback={<Loading />}>
    <LazyComponent />
  </Suspense>
</ErrorBoundary>

Measuring Impact

// Measure chunk load time
const startTime = performance.now();
await import('./HeavyComponent');
const loadTime = performance.now() - startTime;
console.log(`Chunk loaded in ${loadTime}ms`);