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.
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.