DNAðŸ“Ķ Webpack & Build ToolsCode Splitting & Tree Shaking
ðŸĶ–DinosaurWebpackCode SplittingTree ShakingPerformance

Code Splitting & Tree Shaking

Code splitting delivers only the JavaScript users need right now; tree shaking removes the JavaScript they'll never need at all. Together they're the most impactful webpack optimizations you can make.

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:

PropertyPurpose
testMatch modules by path (regex, string, or function)
nameName of the generated chunk
priorityHigher priority groups are matched first
chunks'all', 'async', or 'initial'
minChunksModule must appear in at least N chunks
reuseExistingChunkReuse existing chunk if modules match
enforceIgnore 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 CommentEffect
webpackChunkName: "name"Names the chunk (appears in chunkFilename)
webpackPrefetch: trueAdds <link rel="prefetch"> — loads in idle time
webpackPreload: trueAdds <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:

  1. usedExports: true — Webpack analyzes imports and marks subtract, multiply, divide as unused
  2. Webpack emits: /* unused harmony export subtract, multiply, divide */
  3. 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 gone

The 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 included

Barrel 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 effects

4. Re-exported Namespaces

import * as everything from './module';
export { everything }; // webpack keeps the entire namespace

Webpack 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 icons

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