Code Splitting & Tree Shaking
A 2MB JavaScript bundle is a performance death sentence. Code splitting breaks your application into smaller chunks loaded on demand. Tree shaking eliminates dead code that was imported but never used. Together, they can cut bundle sizes by 40-70% in real-world applications.
Code Splitting Strategies
There are three approaches, and production apps typically use all three together.
1. Multiple Entry Points
Each entry creates a separate chunk:
module.exports = {
entry: {
home: './src/pages/home.js',
dashboard: './src/pages/dashboard.js',
},
output: {
filename: '[name].[contenthash:8].js',
},
};Problem: If both entries import React, React code is duplicated in both bundles. That's where SplitChunksPlugin comes in.
2. Dynamic Imports
import() returns a Promise and creates a separate chunk automatically:
// Static import â always in the main bundle
import { Chart } from './Chart';
// Dynamic import â loaded on demand in a separate chunk
const loadChart = () => import('./Chart');
// React lazy + Suspense pattern
const Chart = React.lazy(() => import('./Chart'));
function Dashboard() {
return (
<Suspense fallback={<Spinner />}>
<Chart data={data} />
</Suspense>
);
}Dynamic imports are the primary mechanism for route-based code splitting:
const routes = [
{
path: '/dashboard',
component: React.lazy(() => import(
/* webpackChunkName: "dashboard" */
'./pages/Dashboard'
)),
},
{
path: '/settings',
component: React.lazy(() => import(
/* webpackChunkName: "settings" */
'./pages/Settings'
)),
},
];3. SplitChunksPlugin
The built-in SplitChunksPlugin automatically extracts common dependencies into shared chunks:
module.exports = {
optimization: {
splitChunks: {
chunks: 'all', // split both async AND sync chunks
minSize: 20000, // minimum chunk size (bytes)
maxSize: 244000, // try to split chunks larger than this
minChunks: 1, // minimum number of chunks sharing a module
maxAsyncRequests: 30,
maxInitialRequests: 30,
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: -10,
},
react: {
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
name: 'react-vendor',
chunks: 'all',
priority: 20,
},
common: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
name: 'common',
},
},
},
},
};How Cache Groups Work
Cache groups define rules for grouping modules into chunks:
| Property | Purpose |
|---|---|
test | Match modules by path (regex, string, or function) |
name | Name of the generated chunk |
priority | Higher priority groups are matched first |
chunks | 'all', 'async', or 'initial' |
minChunks | Module must appear in at least N chunks |
reuseExistingChunk | Reuse existing chunk if modules match |
enforce | Ignore minSize, minChunks, etc. |
Interview insight: The default chunks: 'async' only splits dynamically imported code. Changing to chunks: 'all' also splits synchronous shared imports â this one setting can dramatically reduce duplication.
Chunk Naming with Magic Comments
import(
/* webpackChunkName: "pdf-viewer" */
/* webpackPrefetch: true */
'./components/PDFViewer'
);| Magic Comment | Effect |
|---|---|
webpackChunkName: "name" | Names the chunk (appears in chunkFilename) |
webpackPrefetch: true | Adds <link rel="prefetch"> â loads in idle time |
webpackPreload: true | Adds <link rel="preload"> â loads in parallel |
webpackMode: "lazy" | Default â creates a separate chunk |
webpackMode: "eager" | No separate chunk, but still async |
Prefetch vs. Preload
Prefetch (what user MIGHT need next):
Page loads â idle â browser fetches prefetched chunk in background
Use for: next page, modal content, features after onboarding
Preload (what user WILL need now):
Page loads â browser fetches preloaded chunk in parallel with parent
Use for: critical component in current view loaded via dynamic import// User is on home page, likely to visit dashboard next
const Dashboard = React.lazy(() => import(
/* webpackPrefetch: true */
/* webpackChunkName: "dashboard" */
'./pages/Dashboard'
));
// Chart is needed immediately when Dashboard renders
const Chart = React.lazy(() => import(
/* webpackPreload: true */
/* webpackChunkName: "chart" */
'./components/Chart'
));Caution: webpackPreload should be used sparingly â preloading too many resources competes with critical resources and can hurt performance.
Tree Shaking
Tree shaking is dead code elimination powered by ES module static analysis. Webpack marks unused exports during compilation, and Terser removes them during minification.
How It Works
// math.js â ES module with named exports
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export function multiply(a, b) { return a * b; }
export function divide(a, b) { return a / b; }
// app.js â only imports add
import { add } from './math.js';
console.log(add(1, 2));Webpack's process:
usedExports: trueâ Webpack analyzes imports and markssubtract,multiply,divideas unused- Webpack emits:
/* unused harmony export subtract, multiply, divide */ - Terser reads these annotations and removes the dead code during minification
// After tree shaking + minification:
function add(a, b) { return a + b; }
console.log(add(1, 2));
// subtract, multiply, divide are goneThe sideEffects Flag
The sideEffects field in package.json tells webpack which files are safe to tree shake:
{
"name": "my-library",
"sideEffects": false
}"sideEffects": false means: "If you import something from this package and don't use the export, you can safely remove the entire module." This is critical because some modules execute code at import time (polyfills, CSS imports, global registrations).
{
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfills.js",
"./src/register-components.js"
]
}/*#__PURE__*/ Annotations
For function calls that webpack can't determine are side-effect-free:
// Webpack doesn't know if createStyleSheet() has side effects
const styles = createStyleSheet({ color: 'red' });
// Tell webpack this is pure (safe to remove if unused)
const styles = /*#__PURE__*/ createStyleSheet({ color: 'red' });Babel and TypeScript compilers add /*#__PURE__*/ to common patterns like React.createElement automatically.
Common Tree Shaking Failures
1. CommonJS Modules
// CANNOT be tree shaken â CJS is dynamic
const { add } = require('./math');
// CAN be tree shaken â ESM is statically analyzable
import { add } from './math';Tree shaking fundamentally requires ES module import/export because they are statically analyzable â the dependency structure is known at compile time without executing the code. CommonJS require() can appear inside conditionals, loops, or with computed paths.
2. Barrel Files
// components/index.js (barrel file)
export { Button } from './Button';
export { Modal } from './Modal';
export { Tooltip } from './Tooltip';
export { DataGrid } from './DataGrid'; // 200KB component
// app.js â only needs Button, but...
import { Button } from './components';
// Depending on configuration, DataGrid may still be includedBarrel files can defeat tree shaking when modules have side effects or when sideEffects isn't configured. Direct imports are safer:
import { Button } from './components/Button';3. Assigned-but-Unused Imports
import { utils } from './heavy-lib';
const helper = utils; // assigned but never called
// Webpack may not remove this if it can't prove utils has no side effects4. Re-exported Namespaces
import * as everything from './module';
export { everything }; // webpack keeps the entire namespaceWebpack Configuration for Tree Shaking
module.exports = {
mode: 'production', // enables usedExports + minimizer
optimization: {
usedExports: true, // mark unused exports
minimize: true, // run Terser to remove marked code
concatenateModules: true, // scope hoisting â merges modules
sideEffects: true, // respect package.json sideEffects field
},
};Scope hoisting (concatenateModules) merges small modules into a single function scope, reducing function call overhead and enabling further minification. It's the difference between 100 tiny IIFEs and one lean module.
Bundle Analysis
You can't optimize what you can't measure.
webpack-bundle-analyzer
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static', // generates HTML file
reportFilename: 'bundle-report.html',
openAnalyzer: false,
}),
],
};# One-off analysis without modifying config
npx webpack --profile --json=stats.json
npx webpack-bundle-analyzer stats.json dist/What to Look For
Red flags in bundle analysis:
âââ moment.js with all locales (500KB â 70KB with IgnorePlugin)
âââ lodash full import instead of lodash-es or cherry-picked
âââ Duplicate packages at different versions
âââ Polyfills you don't need (core-js for browsers you don't support)
âââ Dev dependencies in production bundle
âââ Entire icon libraries when you use 3 iconsFixing Common Bloat
// Moment.js: strip unused locales
new webpack.IgnorePlugin({
resourceRegExp: /^\.\/locale$/,
contextRegExp: /moment$/,
}),
// Or better: switch to date-fns (tree-shakeable)
import { format } from 'date-fns'; // only bundles format()Output Optimization
Minification
const TerserPlugin = require('terser-webpack-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
module.exports = {
optimization: {
minimizer: [
new TerserPlugin({
terserOptions: {
compress: {
drop_console: true, // remove console.log
drop_debugger: true,
pure_getters: true,
passes: 2, // multiple compression passes
},
mangle: {
safari10: true,
},
format: {
comments: false,
},
},
extractComments: false,
}),
new CssMinimizerPlugin(),
],
},
};Production Checklist
â mode: 'production'
â contenthash in filenames
â splitChunks.chunks: 'all'
â Separate vendor chunk for stable caching
â sideEffects: false in package.json (if applicable)
â Dynamic imports for routes / heavy features
â Bundle analysis â no obvious bloat
â Source maps: 'hidden-source-map' (upload to error tracking)
â Compression (gzip/brotli via CompressionPlugin or server)
â Image optimization (asset modules + imagemin)Interview Power Moves
- "chunks: 'all' is the single most impactful SplitChunksPlugin setting â it deduplicates shared modules across both sync and async boundaries." Shows practical knowledge.
- "Tree shaking requires ES modules because imports are statically analyzable â CJS require() is dynamic and opaque to the compiler." Demonstrates understanding of the fundamental constraint.
- "Barrel files can silently defeat tree shaking. I prefer direct imports for large packages." A real-world gotcha that shows battle-tested experience.
- "sideEffects: false in package.json tells webpack the entire package is pure â without it, webpack has to assume every module might run code at import time." Deep understanding of tree shaking mechanics.
- "webpackPrefetch is for what users might need next; webpackPreload is for what they need now. Overusing preload competes with critical resources." Nuanced understanding of resource loading priorities.