Back to the blog
Webpack· Jul 29, 2026· 8 min read

Code splitting in Webpack: route-based, component-based, and where it stops helping

Dynamic imports, magic comments, and the exact split points that measurably improved a real app's Time to Interactive - plus the over-splitting mistake that made a bundle slower.

#webpack#code-splitting#performance#lazy-loading

Code splitting's entire value proposition is simple: don't make a user download code for a screen they haven't visited yet. Webpack's mechanism for it - `import()` - is equally simple. The judgment call is where to put the split points, and that's where most teams either under-split (one giant bundle) or over-split (hundreds of tiny requests that each carry their own overhead).

The baseline: route-based splitting

// Every top-level route becomes its own chunk automatically
const Dashboard = React.lazy(() => import('./routes/Dashboard'));
const Settings = React.lazy(() => import('./routes/Settings'));
const Billing = React.lazy(() => import(/* webpackChunkName: 'billing' */ './routes/Billing'));

Route-based splitting is the highest-leverage, lowest-risk place to start, because route boundaries already match user navigation boundaries - a user who never opens Billing never pays for its code. The `webpackChunkName` magic comment isn't cosmetic: named chunks are dramatically easier to identify in a bundle analyzer and in browser devtools' network tab six months later.

Component-based splitting for genuinely heavy, rarely-used UI

// A rich-text editor, chart library, or PDF viewer used on one screen
const RichTextEditor = React.lazy(() =>
  import(/* webpackChunkName: 'editor' */ './components/RichTextEditor'),
);

function PostEditor() {
  const [showEditor, setShowEditor] = useState(false);
  return showEditor ? (
    <Suspense fallback={<EditorSkeleton />}><RichTextEditor /></Suspense>
  ) : (
    <button onClick={() => setShowEditor(true)}>Write a post</button>
  );
}

This pattern earns its complexity for genuinely heavy dependencies - a rich text editor, a charting library, a PDF renderer - where the library itself is 100KB+ and only a fraction of users ever trigger the feature that needs it. It does not earn its complexity for a 4KB button component; the Suspense boundary and loading state overhead cost more than the split saves.

The mistake: splitting past the point of diminishing returns

We once split a dashboard into 40+ chunks - one per widget - on the theory that more granularity is always better. It measurably regressed Time to Interactive, because HTTP/2 multiplexing has real per-request overhead (header compression state, browser connection limits, chunk-loading runtime bookkeeping), and 40 small requests firing at once from React.lazy waterfalls contended with each other worse than 6 medium ones would have. We consolidated back to route + genuinely-heavy-component splits and TTI improved 340ms.

SplitChunksPlugin: separating vendor code from app code

module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor',
          priority: -10,
        },
      },
    },
  },
};

This is orthogonal to route splitting: it separates your rarely-changing dependencies (React, your UI library) into their own long-cached chunk, so a deploy that only touches app code doesn't invalidate the vendor chunk's browser cache. Ship both - route splitting for what users download, vendor splitting for what they re-download on every deploy.

How we decide where to split

  • Every top-level route: yes, always - it's free and matches navigation.
  • A component over ~50KB minified, used on fewer than half of sessions: yes.
  • Anything under ~10KB: no - the Suspense/chunk overhead isn't worth it.
  • Above-the-fold content on the primary landing route: never split it - you'd be trading a slower first paint for a faster hypothetical second visit.

Written by Appesto Engineering.