Skip to content

Fix: Webpack Bundle Size Too Large — Reduce JavaScript Bundle for Faster Load Times

FixDevs ·

Quick Answer

How to reduce Webpack bundle size — code splitting, tree shaking, dynamic imports, bundle analysis, moment.js replacement, lodash optimization, and production build configuration.

The Problem

Webpack warns about large bundle sizes:

WARNING in entrypoint size limit: The following entrypoint(s) combined asset size
exceeds the recommended limit (244 KiB). This can impact web performance.

Entrypoints:
  main (1.23 MiB)
      vendors.js
      main.js

Or initial page load is slow and Chrome DevTools shows a large JS payload:

Coverage tab: 78% of JavaScript is unused on initial load
Network tab: main.js — 1.4 MB transferred, 4.2 MB uncompressed

Or bundle analysis reveals unexpected large dependencies:

moment.js: 67.9 KB gzipped (includes all locales)
lodash: 71.9 KB gzipped (only 3 functions used)
@aws-sdk: 240 KB gzipped (full SDK included)

Why This Happens

Large bundles accumulate from several common patterns:

  • No code splitting — the entire app ships in one bundle, including code for routes the user hasn’t visited.
  • Tree shaking not working — dead code isn’t eliminated because imports use CommonJS syntax (require()) or libraries don’t mark themselves as side-effect-free.
  • Large libraries imported in fullimport _ from 'lodash' includes all 71KB of lodash even if only _.debounce is used.
  • Duplicate dependencies — multiple versions of the same library bundled because of version conflicts in node_modules.
  • No compression — the bundle isn’t gzip or Brotli compressed before serving.
  • Development builds in productionNODE_ENV=development includes extra debugging code, disables minification, and enables source maps.

Fix 1: Analyze the Bundle First

Before optimizing, identify what’s actually large:

# Install bundle analyzer
npm install --save-dev webpack-bundle-analyzer

# Run build and open the visualization
npx webpack --profile --json > stats.json
npx webpack-bundle-analyzer stats.json

# Or add to webpack.config.js:
// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  plugins: [
    process.env.ANALYZE && new BundleAnalyzerPlugin({
      analyzerMode: 'static',          // Output HTML report
      reportFilename: 'bundle-report.html',
      openAnalyzer: true,
    }),
  ].filter(Boolean),
};
# Run analysis
ANALYZE=true npm run build
# Opens interactive treemap — hover over blocks to see sizes

What to look for:

  • Unexpectedly large single files (e.g., full moment.js locale data)
  • Duplicate modules (same library appearing multiple times)
  • Development-only code in production bundles
  • Libraries that should be code-split but are in the main bundle

Fix 2: Enable Code Splitting

Split the bundle into chunks that load on demand:

// webpack.config.js
module.exports = {
  mode: 'production',
  optimization: {
    splitChunks: {
      chunks: 'all',          // Split async and initial chunks
      minSize: 20000,         // Only split chunks larger than 20KB
      maxAsyncRequests: 30,   // Max parallel requests for async chunks
      maxInitialRequests: 30, // Max parallel requests for initial chunks
      cacheGroups: {
        // Vendor chunk — stable dependencies cached separately
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'initial',
          priority: -10,
        },
        // React chunk — changes rarely, cache aggressively
        react: {
          test: /[\\/]node_modules[\\/](react|react-dom|react-router)[\\/]/,
          name: 'react-vendor',
          chunks: 'all',
          priority: 20,
        },
        // Common chunk — shared between multiple entry points
        common: {
          name: 'common',
          minChunks: 2,         // Used in at least 2 chunks
          chunks: 'initial',
          priority: -20,
          reuseExistingChunk: true,
        },
      },
    },
    runtimeChunk: 'single',   // Separate runtime chunk for long-term caching
  },
};

Dynamic imports — load route components on demand:

// BEFORE — everything loaded upfront
import Dashboard from './pages/Dashboard';
import Settings from './pages/Settings';
import Analytics from './pages/Analytics';

// AFTER — each page loads only when visited
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));
const Analytics = React.lazy(() => import('./pages/Analytics'));

// With React Router
const routes = [
  { path: '/dashboard', component: React.lazy(() => import('./pages/Dashboard')) },
  { path: '/settings', component: React.lazy(() => import('./pages/Settings')) },
];

// With named chunks — for better debugging
const Dashboard = React.lazy(
  () => import(/* webpackChunkName: "dashboard" */ './pages/Dashboard')
);

Fix 3: Fix Tree Shaking

Webpack’s tree shaking removes unused exports from ES modules. Several things break it:

Ensure ES module syntax:

// WRONG — CommonJS imports disable tree shaking
const { debounce } = require('lodash');   // Entire lodash bundled

// CORRECT — ES module imports enable tree shaking
import { debounce } from 'lodash-es';     // Only debounce bundled (if lodash-es is used)

// Or use direct submodule imports (works with original lodash):
import debounce from 'lodash/debounce';   // Only loads debounce module
import throttle from 'lodash/throttle';

Mark packages as side-effect-free:

// package.json — tell Webpack this package has no side effects
{
  "sideEffects": false
}

// Or specify which files DO have side effects:
{
  "sideEffects": [
    "*.css",
    "*.scss",
    "./src/polyfills.js"
  ]
}

Production mode enables tree shaking automatically:

// webpack.config.js
module.exports = {
  mode: 'production',  // Enables tree shaking + minification + other optimizations
  // Don't manually set optimization.usedExports — mode: 'production' does this

  optimization: {
    usedExports: true,   // Mark unused exports (for tree shaking)
    minimize: true,      // Minify output (Terser removes dead code)
  },
};

Fix 4: Replace Large Libraries

Some libraries are much larger than necessary for common use cases:

Replace moment.js (67KB gzip):

# Option 1 — date-fns (tree-shakeable, ~13KB for common functions)
npm install date-fns
// Before
import moment from 'moment';
const formatted = moment(date).format('MMMM D, YYYY');
const diff = moment(end).diff(moment(start), 'days');

// After — date-fns (tree-shakeable)
import { format, differenceInDays } from 'date-fns';
const formatted = format(date, 'MMMM d, yyyy');
const diff = differenceInDays(end, start);
// Option 2 — If you must use moment, strip unused locales
const webpack = require('webpack');

module.exports = {
  plugins: [
    // Only include English locale — cuts moment from 67KB to ~18KB gzip
    new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /en/),
    // For multiple locales:
    // new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /en|fr|de/),
  ],
};

Replace lodash (71KB gzip):

# Option 1 — lodash-es (same API, ES modules, fully tree-shakeable)
npm install lodash-es

# Option 2 — babel-plugin-lodash (auto-converts lodash imports to submodule imports)
npm install --save-dev babel-plugin-lodash lodash-webpack-plugin
// With babel-plugin-lodash — no code changes needed
// import { debounce, throttle } from 'lodash' automatically becomes:
// import debounce from 'lodash/debounce'
// import throttle from 'lodash/throttle'

Replace full @aws-sdk with specific clients:

// WRONG — imports entire AWS SDK
import AWS from 'aws-sdk';
const s3 = new AWS.S3();

// CORRECT — AWS SDK v3 modular imports
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
const client = new S3Client({ region: 'us-east-1' });
await client.send(new GetObjectCommand({ Bucket, Key }));

Fix 5: Configure Production Build Correctly

Ensure production builds are fully optimized:

// webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = (env) => ({
  mode: env.production ? 'production' : 'development',

  // Never include source maps in production (adds 3-10x bundle size)
  devtool: env.production ? false : 'eval-cheap-module-source-map',

  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            drop_console: true,        // Remove console.log in production
            drop_debugger: true,       // Remove debugger statements
            pure_funcs: ['console.log', 'console.info'],
          },
          format: {
            comments: false,           // Remove comments
          },
        },
        extractComments: false,
      }),
      new CssMinimizerPlugin(),
    ],
  },

  plugins: [
    // Inject NODE_ENV — enables library dead code elimination
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('production'),
    }),
  ],
});

Verify you’re building in production mode:

# Check the bundle mode
npm run build -- --env production

# Or inspect the output — production builds are minified
# If you see readable variable names, you're in development mode

Fix 6: Enable Compression

Serve gzip or Brotli compressed bundles — reduces transfer size by 70-80%:

# Webpack CompressionPlugin — pre-compress during build
npm install --save-dev compression-webpack-plugin
// webpack.config.js
const CompressionPlugin = require('compression-webpack-plugin');

module.exports = {
  plugins: [
    new CompressionPlugin({
      algorithm: 'brotliCompress',     // Brotli — better than gzip (modern browsers)
      test: /\.(js|css|html|svg)$/,
      compressionOptions: { level: 11 },
      threshold: 10240,                // Only compress files larger than 10KB
      minRatio: 0.8,                   // Only compress if result is 20% smaller
      filename: '[path][base].br',     // Output: main.js.br
    }),
    new CompressionPlugin({
      algorithm: 'gzip',              // Gzip fallback for older browsers
      test: /\.(js|css|html|svg)$/,
      filename: '[path][base].gz',
    }),
  ],
};

nginx — serve pre-compressed files:

# nginx.conf
server {
    # Brotli support
    brotli on;
    brotli_static on;   # Serve .br files if they exist

    # Gzip fallback
    gzip on;
    gzip_static on;     # Serve .gz files if they exist
    gzip_types text/javascript application/javascript text/css;

    location /static/ {
        # Cache hashed assets for 1 year
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

Fix 7: Set Performance Budgets

Prevent bundle size regressions with Webpack performance hints:

// webpack.config.js
module.exports = {
  performance: {
    hints: 'error',              // Fail the build if limits exceeded (use 'warning' in dev)
    maxEntrypointSize: 250000,   // 250KB gzip budget for initial bundles
    maxAssetSize: 250000,        // 250KB per individual asset

    // Custom filter — only apply to JS and CSS
    assetFilter: (assetFilename) =>
      /\.(js|css)$/.test(assetFilename) && !assetFilename.includes('.map'),
  },
};

Measure real-world impact — Lighthouse:

# Run Lighthouse from command line
npm install -g lighthouse
lighthouse https://yourapp.com --output html --output-path report.html

# Key metrics to check:
# - First Contentful Paint (FCP): < 1.8s is good
# - Total Blocking Time (TBT): < 200ms is good
# - JavaScript execution time: reduce if > 2s

Bundle size tracking in CI:

# bundlesize — fail CI if bundle exceeds limits
npm install --save-dev bundlesize
// package.json
{
  "bundlesize": [
    { "path": "./dist/main.*.js", "maxSize": "200 kB" },
    { "path": "./dist/vendors.*.js", "maxSize": "150 kB" }
  ],
  "scripts": {
    "check-size": "bundlesize"
  }
}

Still Not Working?

Duplicate React in bundle — if React appears twice in the bundle (common in monorepos or when using npm link), it’s because two packages resolved different React instances. Fix by aliasing React to a single path:

// webpack.config.js
resolve: {
  alias: {
    react: path.resolve('./node_modules/react'),
    'react-dom': path.resolve('./node_modules/react-dom'),
  },
},

import * prevents tree shakingimport * as utils from './utils' imports everything. Use named imports instead.

CSS not extracted — CSS bundled inside JavaScript adds to JS size and blocks rendering. Use MiniCssExtractPlugin to extract CSS into separate files:

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  plugins: [new MiniCssExtractPlugin()],
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
    ],
  },
};

Images in the bundle — if url-loader is configured to inline all images as base64, large images inflate the JS bundle. Set a size limit: { loader: 'url-loader', options: { limit: 8192 } } (only inline images smaller than 8KB).

For related issues, see Fix: Vite Build Chunk Size Warning and Fix: Webpack HMR Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles