Skip to main content
Saved

Code Splitting

Category Performance

Level Advanced

Examples

React, Vue, Svelte, Native web

Break application code into smaller bundles loaded on demand to reduce initial load time.

In this article
  1. Problem
  2. Solution
  3. Example
  4. Route-Based Splitting
  5. Component-Level Splitting
  6. Named Chunks and Prefetching (Webpack)
  7. Feature Flag Splitting
  8. Vendor Splitting Configuration
  9. Error Handling and Preloading
  10. Benefits
  11. Tradeoffs
  12. Summary
  13. See also

Code Splitting

Problem

Your homepage takes 8 seconds to become interactive because users download the entire admin dashboard, a rich text editor they’ll never use, three charting libraries, and every route they might someday visit, all before clicking a single button.

I’ve seen production bundles ship 2MB of JavaScript for landing pages that only needed 100KB, leaving mobile users on 3G staring at blank screens for 15 seconds. Even users with fast connections wait for parsing, because the browser has to parse all that JavaScript whether it runs or not.

Most of this code runs features users might never open: checkout flows for people who aren’t buying, admin panels for people who aren’t admins, analytics dashboards for pages they haven’t clicked.

Solution

Dynamic import() statements split your code at natural boundaries, such as routes or large features. Your bundler creates separate chunk files that load on demand rather than shipping everything upfront.

The most common split point is route boundaries: settings page code downloads only when users navigate there. You can also split at the component level for heavy dependencies like charting libraries, or by feature flags so premium features don’t bloat the bundle for free-tier users.

Bundlers do this automatically. When you write import('./Dashboard') instead of import './Dashboard', Webpack, Rollup, or Vite create a separate chunk for that module and its dependencies. That chunk downloads only when that code path executes.

Example

Route-based splitting is most common, but component-level splitting works well for heavy widgets.

Route-Based Splitting

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

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<LoadingSpinner />}>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/analytics" element={<Analytics />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Component-Level Splitting

Heavy components like charts can be split independently from routes:

import { lazy, Suspense } from 'react';

const Chart = lazy(() => import('./components/Chart'));

function Dashboard({ showChart, data }) {
  return (
    <div>
      <h1>Dashboard</h1>
      {showChart && (
        <Suspense fallback={<ChartSkeleton />}>
          <Chart data={data} />
        </Suspense>
      )}
    </div>
  );
}

Named Chunks and Prefetching (Webpack)

Webpack magic comments give you control over chunk names and loading behavior:

const Dashboard = () => import(
  /* webpackChunkName: "dashboard" */
  /* webpackPrefetch: true */
  './pages/Dashboard'
);

const HeavyEditor = () => import(
  /* webpackChunkName: "editor" */
  /* webpackPreload: true */
  './components/RichTextEditor'
);

Feature Flag Splitting

Dynamic imports with feature flags load code only for enabled features:

// Each import() path is a literal so the bundler can see it and create a chunk.
const features = {
  'advanced-analytics': () => import('./features/AdvancedAnalytics'),
  'admin-panel': () => import('./features/AdminPanel')
};

async function loadFeature(featureName) {
  if (features[featureName]) {
    const module = await features[featureName]();
    return module.default;
  }
  return null;
}

Vendor Splitting Configuration

Separating vendor libraries from app code improves cache efficiency:

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

// vite.config.js (Vite 8, which bundles with Rolldown)
export default {
  build: {
    rolldownOptions: {
      output: {
        codeSplitting: {
          groups: [
            { name: 'react-vendor', test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom)[\\/]/ },
            { name: 'charts', test: /[\\/]node_modules[\\/](chart\.js|d3[^\\/]*)[\\/]/ }
          ]
        }
      }
    }
  }
};

Error Handling and Preloading

Failed chunk loads need a fallback, and preloading on hover starts the download before the user clicks:

// Show a fallback component if the chunk fails to load
async function loadDashboard() {
  try {
    const module = await import('./pages/Dashboard');
    return module.default;
  } catch (error) {
    console.error('Failed to load dashboard:', error);
    return ErrorComponent;
  }
}

// Start loading the chunk when the user hovers over the link
document.querySelector('#dashboard-link').addEventListener('mouseenter', () => {
  import('./pages/Dashboard');
});

Benefits

  • The initial bundle gets smaller, because users download only what they need for the current page.
  • Pages become usable sooner on every device, because less code means less download time, parsing, and compilation before users can interact.
  • Mobile users on slow connections see usable pages faster. The difference between 2MB and 100KB can determine whether users stay or abandon.
  • Unused features add nothing to the download: admin panels stay out of regular user bundles, and premium features don’t slow down free-tier users.
  • Vendor libraries cache independently from app code, so pushing updates doesn’t invalidate users’ cached React or other dependencies.
  • Less JavaScript up front helps loading metrics like FCP and LCP, and less script competing for the main thread helps INP. LCP and INP are both Core Web Vitals, which Google uses as a search ranking signal.

Tradeoffs

  • Navigation feels slower initially. Users see loading spinners when visiting routes for the first time while chunks download.
  • Every split point needs loading UI; without fallbacks, chunk loading causes blank screens and layout shifts.
  • Build configuration grows more complex. Debugging across chunks, source maps, and tuning split points all require extra effort.
  • On HTTP/1.1 connections, many small files hurt performance due to request overhead, though HTTP/2 multiplexing largely removes this overhead.
  • Choosing chunk size is tricky: too many tiny chunks add request overhead, and too few large chunks defeat the purpose.
  • Dynamic imports return promises, so you must handle failed network requests with appropriate error UI.
  • Prefetching too aggressively wastes bandwidth, while not prefetching enough makes navigation feel slow.
  • Shared dependencies can accidentally duplicate across chunks without proper bundler configuration, increasing total size.

Summary

Code splitting breaks your bundle into on-demand chunks, improving initial load times by downloading only what users need. Route boundaries are the usual place to split, heavy features can wait until someone asks for them, and preloading likely next routes keeps navigation fast while the initial download stays small.

See also

Newsletter

A Monthly Email
from Den Odell

Behind-the-scenes thinking on frontend patterns, site updates, and more

No spam. Unsubscribe anytime.