Reducing vendor chunk size in a React app

This walkthrough sits beneath the webpack bundle analysis techniques guide and the broader JavaScript bundle optimization and code splitting effort, and it tackles a specific, common failure: a React app whose vendor.js chunk has ballooned past 400KB gzipped, dragging initial load and inflating main-thread parse beyond the 50ms long-task budget.

A bloated vendor chunk is the single most common bundle pathology in React apps, because the default instinct — "put all of node_modules in one chunk" — quietly accumulates every dependency a single import ever pulled in. The symptom is a slow first paint and a sluggish first interaction; the cause is almost always two or three oversized libraries plus a cache-churn pattern that re-downloads the whole chunk on every deploy. This page moves from baseline measurement to root-cause isolation to numbered, byte-quantified fixes, then verifies the result.

Splitting a monolithic vendor chunk One 400KB vendor chunk versus a stable framework chunk, a slimmer vendor chunk, and a lazy route-only chunk. vendor.js: monolith to cache groups Before vendor.js 400KB re-downloaded on bump After framework ~45KB, stable vendor (slim) deduped, tree-shaken lazy route chunk chart, loads on route Isolate the runtime so a deploy no longer invalidates the whole vendor.

Rapid diagnosis: confirming the vendor chunk is the problem

Before changing config, prove the vendor chunk is the bottleneck and find what is inside it. Run through this DevTools and analyzer checklist:

  • Network tab (Disable cache, Fast 3G throttle): sort by transfer size. If vendor.[hash].js is the largest resource and blocks the route, it is your target.
  • Coverage tab: record a page load and read the unused-bytes percentage for the vendor chunk. Above ~40% unused means dead weight is shipping.
  • Performance panel: look for a single Compile/Evaluate Script task over 50ms — that long task is the vendor chunk parsing on the main thread.
  • webpack-bundle-analyzer: generate the treemap and note the three biggest rectangles. Those three libraries are where 80% of your savings live. (See how to configure webpack bundle analyzer for production if you have not set this up.)
bash
# Produce a static treemap from your production stats
npx webpack --mode production --json > stats.json
npx webpack-bundle-analyzer stats.json dist --mode static --report report.html
# trade-off: analyze a PRODUCTION build only — dev builds include HMR runtime
# and unminified deps, so their treemap proportions mislead you.

Root cause analysis: why vendor chunks balloon in React apps

Failure mode 1 — the monolithic node_modules catch-all. A single cacheGroups rule with test: /node_modules/ lumps React, your UI kit, your charting library, and a date library into one file. Any version bump to any dependency invalidates the entire chunk's hash, so returning users re-download all 400KB even though only one library changed.

Failure mode 2 — a heavy library imported at the namespace level. import * as Icons from 'react-icons' or import _ from 'lodash' pulls the whole package because the import defeats static analysis. The mechanism is the same dead-code barrier covered in fixing tree-shaking issues with lodash and moment.

Failure mode 3 — an always-loaded heavy dependency that only one route needs. A charting library (often 150KB+) or a rich-text editor lives in the vendor chunk and ships to every user, even those who never open the dashboard route that uses it. It belongs in a route-level dynamic import, not the shared vendor file.

Failure mode 4 — duplicate copies of the same dependency. Two transitive deps requesting different minor versions of, say, a polyfill produce two copies inside vendor. The treemap shows the same package name twice — pure waste.

Step-by-step resolution: numbered fixes by impact

1. Split the monolith into stable cache groups

Carve the rarely-changing framework core away from volatile app dependencies so a single dependency bump no longer invalidates everything.

js
// webpack.config.js
module.exports = {
  optimization: {
    runtimeChunk: 'single', // isolate the webpack runtime so its hash churn
                            // doesn't invalidate vendor on every build
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        framework: {
          test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
          name: 'framework', priority: 40, enforce: true,
        },
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor', priority: 10,
        },
      },
    },
  },
  // trade-off: more chunks means more HTTP requests; on HTTP/1.1 origins the
  // request overhead can outweigh the cache-stability win, so don't over-split.
};

Expected outcome: isolates ~45KB of stable framework code into a long-lived chunk; reduces the bytes re-downloaded per deploy by the framework's share (often ~30-40% of repeat-visit transfer).

2. Lazy-load route-only heavy dependencies

Move the charting library, editor, or map component out of vendor and behind React.lazy so it loads only on the route that uses it.

jsx
import { lazy, Suspense } from 'react';
// Chart (and its ~150KB dep tree) now splits into its own async chunk.
const Dashboard = lazy(() => import('./routes/Dashboard'));

export function Routes() {
  return (
    <Suspense fallback={<Spinner />}>
      <Dashboard />
    </Suspense>
  );
  // trade-off: lazy boundaries add a loading state and a round-trip on first
  // navigation; don't lazy-load components used on the very first paint or you
  // trade a smaller vendor chunk for a worse LCP.
}

Expected outcome: removes the route-specific library from every page's critical path. Moving a 150KB (≈45KB gzipped) charting lib out of vendor cuts the initial vendor transfer by roughly that amount.

3. Convert namespace imports to named/deep imports

Replace whole-package imports with named or path imports so tree-shaking can prune the unused surface.

jsx
// BEFORE: pulls the entire icon set into vendor
// import * as Icons from 'react-icons/fa';
// AFTER: only the icons you use survive tree-shaking
import { FaUser, FaCog } from 'react-icons/fa';
// trade-off: deep/named imports rely on the package shipping ESM with
// sideEffects:false — if it ships CommonJS, this won't shake; replace the dep.

Expected outcome: for an icon or utility library, typically reduces that library's vendor contribution from tens of KB to single-digit KB gzipped.

4. Deduplicate and right-size individual libraries

Resolve duplicate copies and swap heavyweight libraries for lean equivalents.

js
// webpack.config.js — force a single copy of a duplicated dependency
module.exports = {
  resolve: {
    alias: {
      // collapse two requested ranges to one resolved copy
      'date-fns': require.resolve('date-fns'),
    },
  },
  // trade-off: aliasing to one version can break a transitive dep that relied
  // on the other range's API — run the test suite after pinning.
};

Alongside this, swap moment (~18KB gzipped core, more with locales) for date-fns or dayjs (~2KB core). Expected outcome: deduplication removes the redundant copy outright; the moment→dayjs swap saves ~15KB gzipped.

Verification: proving the chunk shrank and stayed shrunk

Re-run the same diagnosis against the new build and capture a before/after diff:

  • Before/after analyzer treemap: the vendor rectangle should be visibly smaller and the framework chunk should appear separately. Confirm no duplicate package names remain.
  • Network transfer: with cache disabled, the sum of framework + vendor initial JS should land under the 150KB gzipped initial-route budget.
  • Performance panel: the previously >50ms vendor Evaluate Script task should now be split across smaller chunks, each under the long-task threshold — the same responsiveness win discussed in optimizing First Input Delay and INP.
  • CI assertion: lock the win in so it cannot regress.
js
// CI budget — fail the build if vendor grows past target (bytes, gzipped)
module.exports = {
  performance: {
    hints: 'error',
    maxAssetSize: 160_000,       // per-asset cap
    maxEntrypointSize: 170_000,  // initial route budget
  },
  // trade-off: hard error budgets can block urgent hotfixes that legitimately
  // add bytes; pair the gate with an explicit override label for emergencies.
};

A field check closes the loop: watch your RUM p75 for the affected routes after the deploy. The repeat-visit transfer should drop (thanks to the stable framework chunk) and the first-interaction metrics should improve as the smaller chunks parse faster.