ReactCode Splitting Strategies

Code Splitting Strategies

A React app compiled without code splitting produces a single JavaScript file — often 500 KB to several megabytes. Every visitor downloads this entire file before the page becomes interactive, even if they only use 10% of it. Code splitting divides that bundle into smaller chunks that load on demand. The result: faster Time to Interactive (TTI), better Lighthouse scores, and a smoother experience on slow connections.

Why Bundle Size Matters

JavaScript is expensive in two ways: downloading it costs bandwidth, and parsing and executing it blocks the main thread. A 1 MB JS bundle on a mid-range phone can take 5–8 seconds to parse — before a single pixel is interactive. Code splitting makes the initial chunk as small as possible; additional chunks load in the background or on-demand.

Metric

Impact of large initial bundle

Time to Interactive (TTI)

Delayed — JS must parse before UI responds

First Contentful Paint

Slightly delayed — parser block

Lighthouse Performance

Lower score, especially on mobile simulation

Bounce rate

Higher — users leave slow pages

Strategy 1: Route-Based Splitting

Route-based splitting is the most impactful change you can make. Each page of your app becomes its own chunk — users who only visit the homepage never download the dashboard code. With React Router v6 and React.lazy:

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

// webpack / Vite sees import() and creates a separate chunk for each module.
// The magic comment gives the chunk a readable name in DevTools.
const Home      = lazy(() => import(/* webpackChunkName: "page-home" */      './pages/Home'))
const Dashboard = lazy(() => import(/* webpackChunkName: "page-dashboard" */ './pages/Dashboard'))
const Settings  = lazy(() => import(/* webpackChunkName: "page-settings" */  './pages/Settings'))
const Reports   = lazy(() => import(/* webpackChunkName: "page-reports" */   './pages/Reports'))

export default function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<PageLoader />}>
        <Routes>
          <Route path="/"           element={<Home />} />
          <Route path="/dashboard"  element={<Dashboard />} />
          <Route path="/settings"   element={<Settings />} />
          <Route path="/reports"    element={<Reports />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  )
}

function PageLoader() {
  return <div style={{ textAlign: 'center', padding: 64 }}>Loading…</div>
}
Note
The /* webpackChunkName: "..." */ magic comment is optional but recommended. Without it, chunks are named by numeric ID (42.js) which is hard to debug. With it you see "page-dashboard.js" in the Network tab.
Strategy 2: Component-Based Splitting

Some components are heavy but only needed conditionally — a rich text editor, a data grid, a PDF viewer. Lazy-load them so their code is never downloaded until the user actually needs them:

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

// These libraries are large — don't pay the cost until they're needed
const DataGrid   = lazy(() => import(/* webpackChunkName: "data-grid" */  './DataGrid'))
const PDFViewer  = lazy(() => import(/* webpackChunkName: "pdf-viewer" */ './PDFViewer'))
const VideoPlayer = lazy(() => import(/* webpackChunkName: "video" */     './VideoPlayer'))

function ContentPage({ type, src }) {
  return (
    <Suspense fallback={<div>Loading viewer…</div>}>
      {type === 'grid'  && <DataGrid src={src} />}
      {type === 'pdf'   && <PDFViewer src={src} />}
      {type === 'video' && <VideoPlayer src={src} />}
    </Suspense>
  )
}
Strategy 3: Vendor / Library Splitting

Your own code changes on every deployment — but third-party libraries like React and Lodash rarely change. Modern bundlers separate these into a "vendor" chunk that the browser caches across deployments. Configure this in Vite's vite.config.ts:

TS
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // Rarely-changing libraries — long-lived cache
          'vendor-react':  ['react', 'react-dom', 'react-router-dom'],
          'vendor-ui':     ['@mui/material', '@emotion/react', '@emotion/styled'],
          'vendor-charts': ['recharts', 'd3'],
        },
      },
    },
  },
})

With this config, after you ship a bug fix your users re-download only your application code. The vendor chunk — which may be hundreds of KB — is served from the browser cache.

Webpack Magic Comments

webpack supports several magic comments inside import() to control chunk behaviour:

JS
// Named chunk — appears as "heavy-editor.js" in the network tab
const Editor = lazy(() => import(/* webpackChunkName: "heavy-editor" */ './Editor'))

// Prefetch — browser downloads this chunk during idle time (low priority)
// Use for routes the user is likely to visit next
const Settings = lazy(() => import(/* webpackPrefetch: true */ './Settings'))

// Preload — browser downloads this chunk at high priority in parallel
// Use for resources needed very soon
const Modal = lazy(() => import(/* webpackPreload: true */ './Modal'))

// Combine name + prefetch
const Analytics = lazy(() => import(
  /* webpackChunkName: "analytics", webpackPrefetch: true */
  './Analytics'
))
Warning
webpackPreload injects a <link rel="preload"> tag which increases bandwidth usage at startup. Only use it for chunks the current page will definitely need. webpackPrefetch uses <link rel="prefetch"> (low priority, idle-time) and is safer for speculative loading.
Vite Automatic Splitting

Vite (powered by Rollup) splits chunks automatically — every dynamic import() becomes its own file. You don't need to configure anything:

JSX
// In Vite, this is all you need — no plugin config required
const AdminPanel = lazy(() => import('./AdminPanel'))
// Vite creates: dist/assets/AdminPanel-[hash].js automatically

// Vite also splits node_modules automatically.
// You can inspect the output with:
// npx vite build --report  (opens a bundle visualizer)
Measuring Impact

Code splitting is only valuable if you measure its effect. Three tools that show you exactly what's in your bundle:

  • Chrome DevTools → Network tab — filter by "JS", navigate between routes, watch chunk files load on demand. The number after "Transferred" shows what was actually downloaded.

  • Lighthouse — run in incognito. "Reduce unused JavaScript" and "Remove unused CSS" in the Opportunities section show exactly which modules are dead weight.

  • webpack-bundle-analyzernpm install --save-dev webpack-bundle-analyzer, then npx webpack --profile --json > stats.json && npx webpack-bundle-analyzer stats.json. Shows a zoomable treemap of every module.

  • Vite bundle visualizernpm install --save-dev rollup-plugin-visualizer, add visualizer() to Vite plugins. Opens an interactive treemap after vite build.

  • source-map-explorernpx source-map-explorer dist/static/js/*.js. Works with any bundler that produces source maps.

A Practical Workflow
  1. Run npm run build and note the total JS size.

  2. Add route-based splitting for every top-level route — this is the single best ROI.

  3. Re-build and compare. Most apps see a 40–70% reduction in initial bundle.

  4. Open bundle-analyzer, find the largest modules in the initial chunk.

  5. Lazy-load any that are conditional (modals, panels, editors).

  6. Check webpackPrefetch for routes users commonly navigate to next.

  7. Re-run Lighthouse in mobile simulation and verify TTI improvement.

Tip
Aim for an initial JS bundle under 150 KB gzipped for most apps. Route chunks can be larger since the user is waiting for a navigation anyway — but the first paint should be fast.