JavaScript Code Splitting: 3 Patterns, 2 Gotchas, 0 Guesswork

2026-04-15 · Nico Brandt

You ran Lighthouse, saw a 1.2 MB JavaScript bundle, and code splitting is the obvious fix. Every tutorial says the same thing: import(), React.lazy, done.

Here’s what they skip: javascript code splitting done wrong — too many tiny chunks, broken vendor splits, zero error handling — makes performance worse than one fat bundle.

Code splitting breaks your bundle into smaller chunks loaded on demand, shipping only what the current page needs. Three strategies: route-based (load per page), component-based (load when visible), vendor-based (split third-party libs for caching). The question isn’t how. It’s what to split, when to stop, and which gotcha will bite you in production.

Three Strategies, One Decision Framework

Every javascript code splitting guide throws patterns at you. None give you a decision framework for when to use each — or when to stop.

Here’s the tree:

Route-based splitting — do this always. No exceptions. Each page loads only its own JavaScript. Highest impact, lowest risk.

Component-based splitting — only for components over 50 KB that live below the fold or behind a user interaction. Heavy chart libraries, rich text editors, map widgets. Don’t split your navbar.

Vendor splitting — split third-party libraries from your application code, grouped by how often they change. Your app code changes daily. React changes every few months. Lodash changes never. Separate them so the browser cache does the real work.

The rule nobody mentions: if your initial bundle is under 200 KB gzipped, stop reading. You don’t need this yet. Code splitting has real overhead — extra HTTP requests, loading waterfalls, error handling complexity. Don’t optimize what isn’t broken. If you’re not sure where you stand, a bundle analyzer shows you in five minutes.

But if your bundle is fat, route splitting is where you start. The configs are simpler than you think.

Route-Based Splitting: The One You Always Do

Route-based code splitting delivers the biggest win for the least risk. Typical result: 40–60% reduction in initial bundle size. A medium React app goes from a 1.2 MB single bundle to a 380 KB initial load with the rest arriving on navigation.

Four lines of React:

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

<Suspense fallback={<Loading />}>
  <Route path="/dashboard" element={<Dashboard />} />
</Suspense>

That dynamic import() is the split point. Each route becomes its own chunk, loaded when the user navigates there.

Vite handles this automatically. Dynamic imports produce separate chunks with zero config — Vite’s Rollup-based build sees the import() boundary and splits there. If you’re still choosing between bundlers, this is one area where Vite wins on defaults.

Webpack needs a nudge:

optimization: {
  splitChunks: {
    chunks: 'async',
    minSize: 20000,
  }
}

chunks: 'async' tells Webpack to split only dynamic imports — exactly what you want for route-based splitting. The minSize threshold prevents micro-chunks that cost more in HTTP overhead than they save in bytes.

Routes are handled. But open your bundle analyzer and you’ll spot something: the vendor chunk is still enormous. React, your UI library, lodash, that date formatting package — all crammed into one blob. That’s the next problem. And it has a trap.

Vendor Splits and the React Trap Nobody Warns You About

Vendor splitting exists because third-party code and your application code change at completely different rates. When a user revisits, the browser should re-download only what actually changed. If everything sits in one bundle, changing a single line of your code invalidates the cache for all 800 KB — including React, which hasn’t changed in months.

Split vendors by change frequency:

Chunk Contents Changes
vendor-react React, ReactDOM, React Router Rarely (major upgrades)
vendor-ui Component library (Radix, MUI) On library upgrades
vendor-utils lodash, date-fns, zod Almost never
vendor-analytics Tracking, analytics Frequently

Vite config:

build: {
  rollupOptions: {
    output: {
      manualChunks: {
        'vendor-react': ['react', 'react-dom', 'react-router-dom'],
        'vendor-ui': ['@radix-ui/react-dialog', '@radix-ui/react-popover'],
        'vendor-utils': ['lodash-es', 'date-fns'],
      }
    }
  }
}

Webpack equivalent:

optimization: {
  splitChunks: {
    cacheGroups: {
      reactVendor: {
        test: /[\\/]node_modules[\\/](react|react-dom|react-router)[\\/]/,
        name: 'vendor-react',
        chunks: 'all',
      },
      utilVendor: {
        test: /[\\/]node_modules[\\/](lodash|date-fns)[\\/]/,
        name: 'vendor-utils',
        chunks: 'all',
      }
    }
  }
}

Now the gotcha that’ll eat your Friday afternoon.

You cannot aggressively split React ecosystem libraries. Separate React from libraries that depend on React internals — anything using forwardRef, context, or hooks — and you get cryptic initialization errors in production. The kind that don’t show up in dev mode. One developer documented three failed attempts at aggressive React vendor splitting: aimed for an 82% bundle reduction, had to settle for 26% because splitting React-dependent libraries into separate chunks broke forwardRef calls and caused hydration failures.

The rule: React, ReactDOM, and any library that touches React internals go in the same chunk. Split everything else freely. This isn’t a bundler limitation — it’s a React runtime constraint. Fight it and you lose.

For component-level splitting, same lazy() pattern as routes but scoped to individual components. Use it for heavy widgets — chart renderers, code editors, map embeds — over 50 KB and not needed on initial render. If it’s visible above the fold on load, it shouldn’t be lazy. The lazy loading guide covers the nuance here.

Routes are split. Vendors are grouped by change frequency. React is kept together. But how big should each chunk be? And what happens when one fails to load on a flaky connection?

Chunk Sizing, Over-Splitting, and What to Do When Loads Fail

The sweet spot: 50–500 KB gzipped per chunk. This balances three competing forces.

Too small (under 20 KB) and you lose on compression — small files compress poorly — plus you pay per-request overhead even on HTTP/2. Thirty micro-chunks with their own header bytes and priority scheduling cost more than eight well-sized chunks. HTTP/3 reduces this penalty but doesn’t eliminate it.

Too large (over 500 KB) and you block the main thread. Lighthouse flags JavaScript execution over 2 seconds and fails you at 3.5+. Large chunks also defeat the purpose of splitting — one change invalidates the whole download. That directly hurts INP scores since main thread blocking delays every interaction.

Then there’s the failure nobody plans for. Chunks load over the network. Networks fail. CDN hiccups. Flaky mobile connections drop requests. Your import() rejects and the user sees a white screen.

Handle it:

function loadWithRetry(importFn, retries = 3) {
  return importFn().catch((err) => {
    if (retries > 0) {
      return new Promise((r) => setTimeout(r, 1000))
        .then(() => loadWithRetry(importFn, retries - 1));
    }
    throw err;
  });
}

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

Three retries, one-second delay. Covers transient failures without hammering the server. Wrap your Suspense in an error boundary for when retries don’t help — “something went wrong, reload” beats a blank page every time.

This is the part most performance checklists skip entirely. Your splitting strategy is only as reliable as your failure handling.

Your Splitting Checklist

You came here because your bundle was too big and every guide said “use React.lazy” like that was the whole answer. Now you have a framework.

Three rules:

  1. Split routes — always. Dynamic import() at route boundaries. Biggest impact, lowest risk.
  2. Split vendors by change frequency. React ecosystem stays in one chunk. Everything else groups by update cadence.
  3. Split components only when over 50 KB and off the critical path. Charts, editors, maps. Not navbars.

Stop when your chunks land between 50–500 KB gzipped. Add retry logic for failed loads. And if your initial bundle was under 200 KB — none of this was necessary. Knowing when not to optimize is half the job.

Run npx vite-bundle-visualizer or webpack-bundle-analyzer, find the biggest rectangle, and apply the strategy that fits. No magic. No guesswork. Just fewer bytes where they matter.