Skip to content

Fix: Webpack Bundle Too Large — Chunk Size Warning

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to reduce Webpack bundle size — code splitting, lazy loading, tree shaking, analyzing the bundle with webpack-bundle-analyzer, replacing heavy dependencies, and configuring splitChunks.

The Error

Webpack warns about oversized bundles during build:

WARNING in asset size limit: The following asset(s) exceed the recommended size limit (244 KiB).
This can impact web performance.
Assets:
  main.js (1.23 MiB)

WARNING in entrypoint size limit: The following entrypoint(s) combined asset size exceeds the recommended limit (244 KiB). The limit can be adjusted using "performance.maxEntrypointSize" in webpack config.
Entrypoints:
  main (1.23 MiB)
      main.js

Or Core Web Vitals suffer because users download a huge JavaScript file before the page loads.

The page loads slowly and Lighthouse shows:

Reduce unused JavaScript — Potential savings of 800 KiB
Avoid enormous network payloads — Total size was 2.1 MiB

Why This Happens

A large bundle means users download code they may not need. The 244 KiB default threshold that Webpack enforces is not arbitrary; it roughly corresponds to the parsing and evaluation budget a mid-range mobile device can handle within 2-3 seconds on a 3G connection. Exceeding it degrades Time-to-Interactive measurably.

Several patterns push your bundle past that limit:

  • No code splitting — all routes and components bundled into a single file. Users downloading the login page also download the dashboard, admin panel, and all other routes.
  • Heavy dependencies — libraries like moment.js (67 KB gzipped), lodash (24 KB gzipped), or @material-ui added without care for what’s actually used.
  • No tree shaking — unused exports from libraries are included because imports aren’t written in a tree-shakeable way, or the library ships only CommonJS.
  • Large assets bundled as JavaScript — images, SVGs, or JSON data embedded as base64 or JS objects inflate the parsed bundle even when the actual data is static.
  • Missing production optimization — bundle built without mode: 'production', so minification and dead code elimination don’t run.
  • All polyfills included@babel/preset-env targets too broad a browser range, adding polyfills for features modern browsers support natively.
  • Duplicate packages — different versions of the same library pulled in by transitive dependencies, each bundled separately.

Fix 1: Analyze the Bundle First

Before optimizing, identify what’s actually large with webpack-bundle-analyzer:

npm install -D webpack-bundle-analyzer
// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',      // Opens an HTML report
      reportFilename: 'bundle-report.html',
      openAnalyzer: false,
    }),
  ],
};
npm run build
# Opens bundle-report.html — treemap showing what's in your bundle

The treemap shows each module’s size. Common culprits to look for: entire moment.js locale files, all of lodash, large icon libraries, duplicate packages at different versions.

Quick analysis without the plugin:

npx webpack --json=stats.json
npx webpack-bundle-analyzer stats.json

Bundle Analysis Tools Compared

Different bundlers ship different analysis tooling:

ToolWorks withOutputStrengths
webpack-bundle-analyzerWebpackInteractive treemapShows parsed, stat, and gzipped sizes; hover reveals module paths
rollup-plugin-visualizerRollup, ViteTreemap, sunburst, or network graphMultiple chart types; Vite uses Rollup under the hood
source-map-explorerAny bundlerTreemap from source mapsBundler-agnostic; shows actual byte attribution from source maps
esbuild --analyzeesbuildText tableZero setup; prints top-level size breakdown at build time
Rspack --analyzeRspackWebpack-compatible statsCompatible with webpack-bundle-analyzer since Rspack emits Webpack stats format

If you are evaluating a bundler migration, run the same project through two or more analyzers to compare what each bundler includes. source-map-explorer is especially useful here because it works with any source map regardless of which bundler produced it.

Fix 2: Enable Code Splitting with Dynamic Imports

Split the bundle by route or feature so users only download what they need:

// BEFORE — everything in one bundle
import { Dashboard } from './pages/Dashboard';
import { Settings } from './pages/Settings';
import { AdminPanel } from './pages/AdminPanel';

// AFTER — each page loaded on demand
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));
const AdminPanel = React.lazy(() => import('./pages/AdminPanel'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/admin" element={<AdminPanel />} />
      </Routes>
    </Suspense>
  );
}

Webpack automatically creates separate chunks for each dynamic import. The Dashboard chunk is only downloaded when the user navigates to /dashboard.

Split heavy libraries:

// Load a heavy library only when needed
async function exportToPDF() {
  const { jsPDF } = await import('jspdf');
  const doc = new jsPDF();
  // ...
}

Fix 3: Configure splitChunks for Optimal Caching

Split vendor libraries into separate chunks so they’re cached by the browser independently from your app code:

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        // Split React and ReactDOM into their own chunk
        react: {
          test: /[\\/]node_modules[\\/](react|react-dom|react-router)[\\/]/,
          name: 'vendor-react',
          chunks: 'all',
          priority: 20,
        },
        // Group other large vendors
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
          priority: 10,
          minSize: 50000,  // Only split if > 50KB
        },
      },
    },
    // Extract the runtime into its own tiny chunk
    runtimeChunk: 'single',
  },
};

Benefits:

  • vendor-react chunk rarely changes — long cache lifetime
  • Your app code changes frequently — shorter cache
  • Users only re-download what actually changed on deploy

Fix 4: Replace Heavy Dependencies

The biggest wins often come from swapping heavy libraries for lighter alternatives:

Moment.js to Day.js or date-fns:

# Moment.js: 67 KB gzipped
# Day.js: 2 KB gzipped (same API)
npm install dayjs
npm uninstall moment
// Before
import moment from 'moment';
const date = moment('2026-03-20').format('MMMM D, YYYY');

// After — Day.js (same API, 33x smaller)
import dayjs from 'dayjs';
const date = dayjs('2026-03-20').format('MMMM D, YYYY');

lodash to native methods or lodash-es:

// BEFORE — imports all of lodash (71 KB gzipped)
import _ from 'lodash';
const unique = _.uniq(arr);
const grouped = _.groupBy(items, 'category');

// AFTER — import only what you use (tree-shakeable)
import uniq from 'lodash/uniq';
import groupBy from 'lodash/groupBy';

// OR — use native methods for simple operations
const unique = [...new Set(arr)];

Or use babel-plugin-lodash to automatically cherry-pick:

npm install -D babel-plugin-lodash lodash-webpack-plugin
// babel.config.js
module.exports = {
  plugins: ['lodash'],
};

// webpack.config.js
const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
module.exports = {
  plugins: [new LodashModuleReplacementPlugin()],
};

Icon libraries — import only what you use:

// BEFORE — imports all of Material Icons (1.5 MB)
import { Delete, Edit, Add } from '@mui/icons-material';

// AFTER — import each icon individually (tree-shakeable)
import Delete from '@mui/icons-material/Delete';
import Edit from '@mui/icons-material/Edit';
import Add from '@mui/icons-material/Add';

Fix 5: Tree Shake Unused Code

Webpack tree shakes ES module exports automatically in production mode, but only if imports are written correctly:

// TREE-SHAKEABLE — named imports
import { formatDate, parseDate } from './utils';

// NOT TREE-SHAKEABLE — imports the whole module object
import * as utils from './utils';
utils.formatDate(date);

// NOT TREE-SHAKEABLE — CommonJS require
const { formatDate } = require('./utils');

Enable production mode — tree shaking only runs in production:

// webpack.config.js
module.exports = {
  mode: 'production',  // Enables tree shaking, minification, dead code elimination
};

Mark packages as side-effect-free — tells Webpack it’s safe to remove unused exports:

// package.json (in your own library or app)
{
  "sideEffects": false
}

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

Tree Shaking Across Bundlers

Not all bundlers handle tree shaking equally. The differences become significant in large codebases with many third-party libraries:

Webpack relies on the sideEffects field in package.json and Terser for dead code elimination. It handles re-exports reasonably well, but can miss some cases in deeply nested barrel files. Webpack 5 improved inner-graph analysis, which tracks which exports from a module are actually used by other modules.

Rollup (used by Vite in production builds) generally produces smaller output because it was designed from the start around ES modules. It performs scope hoisting by default, flattening modules into a single scope and removing unused bindings more aggressively. If a library publishes both CJS and ESM, Rollup picks the ESM entry.

esbuild is extremely fast but its tree shaking is less aggressive than Rollup’s. It handles straightforward named exports well, but it may include code from modules that Rollup would drop. The trade-off is build speed: esbuild can be 10-100x faster for large projects.

Rspack aims for Webpack compatibility with Rust-level speed. Its tree shaking behavior mirrors Webpack 5, including sideEffects support and inner-graph analysis, but builds complete in a fraction of the time.

Turbopack (the bundler behind Next.js --turbopack) performs incremental tree shaking at the module level. In development mode, it skips most dead code elimination for speed. In production, it delegates to SWC-based minification.

Parcel auto-detects sideEffects and performs scope hoisting when possible. It requires zero configuration, which is its advantage, but gives you fewer knobs to tune when tree shaking misses something.

Pro Tip: If you suspect tree shaking is failing for a specific library, check whether that library ships a "module" or "exports" field in its package.json. Libraries that only ship CommonJS ("main" field) cannot be tree shaken by any bundler.

Fix 6: Lazy Load Images and Other Assets

Images embedded as base64 in JavaScript dramatically increase bundle size:

// webpack.config.js — inline only tiny images, use file-loader for large ones
module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpg|gif|svg)$/,
        type: 'asset',
        parser: {
          dataUrlCondition: {
            maxSize: 4 * 1024,  // Inline only if < 4KB; otherwise use separate file
          },
        },
      },
    ],
  },
};

For large SVG icon sets, use a sprite or on-demand loading:

// Instead of importing all SVGs upfront
import { ReactComponent as Logo } from './logo.svg';

// For large sets, lazy load:
const Icon = React.lazy(() => import(`./icons/${iconName}.svg`));

Fix 7: Optimize Babel Targets

@babel/preset-env with broad targets includes polyfills for old browsers that most users no longer use. Target only what you actually support:

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', {
      // Before: "last 2 versions" — includes IE 11 polyfills
      // After: modern browsers only
      targets: '> 0.5%, last 2 versions, not dead, not IE 11',

      // Only include polyfills for features actually used
      useBuiltIns: 'usage',
      corejs: 3,
    }],
  ],
};

Or use browserslist in package.json:

{
  "browserslist": [
    "> 0.5%",
    "last 2 versions",
    "not dead",
    "not IE 11"
  ]
}

Removing IE 11 support alone can cut 50-100 KB from the bundle by eliminating polyfills for Promise, fetch, Symbol, and dozens of other features.

Webpack vs Alternative Bundlers

If the bundle size problem persists despite all optimizations, the bundler itself may be part of the issue. Different bundlers have different defaults, and migrating can sometimes solve the problem outright.

Vite (Rollup-based)

Vite uses Rollup for production builds. Rollup’s scope hoisting and aggressive tree shaking often produce 10-20% smaller output from the same source code compared to Webpack. Vite also enforces code splitting by default when you use dynamic imports. The main trade-off is that Vite’s dev server uses native ESM rather than bundling, which means your dev and production pipelines behave differently.

// vite.config.js — equivalent to Webpack splitChunks
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor-react': ['react', 'react-dom'],
        },
      },
    },
    chunkSizeWarningLimit: 500,  // default is 500 KB
  },
});

esbuild

esbuild produces bundles at 10-100x the speed of Webpack. Its output size is comparable to Webpack’s (sometimes slightly larger due to less aggressive tree shaking), but the build speed advantage is compelling for CI pipelines where a 60-second Webpack build drops to under 1 second.

# Build with code splitting
esbuild src/index.tsx --bundle --splitting --format=esm --outdir=dist

Rspack

Rspack is a drop-in Webpack replacement written in Rust. It reads webpack.config.js with near-full compatibility, so migration is minimal. Build times drop by 5-10x while output remains identical to Webpack because it uses the same algorithms. If you are invested in the Webpack plugin ecosystem, Rspack is the lowest-friction migration path.

Turbopack

Turbopack is integrated into Next.js. It excels at incremental builds in development, where it only re-bundles changed modules. For production builds, it is still maturing and currently delegates much of the optimization to SWC. If you are already in the Next.js ecosystem, Turbopack is the expected path forward.

Parcel

Parcel requires zero configuration. It automatically detects entry points, applies code splitting, scope hoisting, and tree shaking. For small to medium projects, this removes the configuration burden that causes many Webpack size issues in the first place — misconfiguration is the most common source of bloat.

When to migrate: If your Webpack config is heavily customized with loaders and plugins you depend on, Rspack is the safest migration. If you are starting a new project or have a simpler setup, Vite or Parcel will produce smaller bundles with less configuration.

Still Not Working?

Check for duplicate packages. If two packages depend on different versions of the same library, both versions get bundled:

npm dedupe               # Flatten dependency tree
npx webpack --stats-all | grep duplicate

Use externals for CDN-loaded libraries. If React is already loaded via a CDN, tell Webpack not to bundle it:

// webpack.config.js
module.exports = {
  externals: {
    react: 'React',       // Use window.React from CDN
    'react-dom': 'ReactDOM',
  },
};

Compress with Brotli or gzip. The actual download size depends on compression, which Webpack doesn’t control — configure your server:

# nginx.conf
gzip on;
gzip_types text/javascript application/javascript;
brotli on;
brotli_types text/javascript application/javascript;

A 500 KB JS file compresses to ~150 KB with gzip and ~120 KB with Brotli. Compression is often more impactful than bundle optimization for initial load time.

Audit with depcheck for unused dependencies. Libraries you npm installed but no longer import still bloat node_modules and can end up in the bundle through transitive imports:

npx depcheck
# Lists unused dependencies you can safely remove

Check for polyfill bundles loaded unconditionally. Some libraries ship their own polyfills (e.g., core-js via @babel/preset-env and also via a library’s own dependencies). Two copies of core-js can add 100+ KB. Pin a single version in your root package.json to deduplicate.

Profile the build itself. Webpack’s --profile flag shows which loaders and plugins consume the most time. A slow build often correlates with unnecessary processing that also inflates output:

npx webpack --profile --json=profile.json

For related issues, see Fix: Vite Build Chunk Size Warning, Fix: Webpack Module Not Found, Fix: esbuild Not Working, and Fix: Rspack 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