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.

Keep two numbers in view throughout. The first is the initial-route JavaScript budget: aim for the shared framework and vendor chunks to sum to under ~150KB gzipped on the first paint, because everything above that competes with your Largest Contentful Paint image for the same download window on mid-tier mobile. The second is the 50ms long-task budget: any single Evaluate Script task over 50ms blocks the main thread and shows up as input delay, which is why one giant vendor chunk hurts responsiveness even when the network is fast. The fixes below are ordered so that the change with the highest byte-for-effort ratio comes first.

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.

Read the treemap by gzipped (or parsed) size, not stat size: webpack-bundle-analyzer defaults to showing stat sizes, which overstate the byte cost of anything that compresses well and understate the parse cost of what does not. Toggle the sidebar to "Gzipped" so the rectangles reflect what actually crosses the wire. For a second opinion that maps bytes back to your original modules, run npx source-map-explorer dist/vendor.*.js — it attributes each byte to a source file, which is invaluable when a single transitive dependency is hiding inside a rectangle labelled only with the top-level package name.

This diagram maps each root cause below to the fix that addresses it, so you can jump straight to the treatment once the treemap names your culprit.

Which vendor-bloat cause maps to which fix The five treemap symptoms — node_modules catch-all, namespace import, route-only heavy dependency, duplicate copies, and a CommonJS-only library — connected to the four numbered fixes: cache groups, lazy route chunk, named imports, and dedupe/right-size. Match each vendor-bloat cause to its fix Failure mode (treemap symptom) Numbered fix 1  node_modules catch-all one content hash for every dep 2  Namespace import import * as Icons from … 3  Route-only heavy dep charting lib ships to every page 4  Duplicate copies two versions in one chunk 5  CommonJS-only lib exports cannot be shaken Fix 1 — cache groups split framework vs vendor Fix 2 — lazy route chunk React.lazy on the route Fix 3 — named imports let tree-shaking prune Fix 4 — dedupe / right-size alias one copy, swap heavy libs

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. The mechanism is npm's dependency resolution: when two ranges are incompatible, npm nests a second physical copy under node_modules/<dep>/node_modules/, and webpack faithfully bundles both.

Failure mode 5 — a CommonJS-only library that cannot be shaken. Even a correctly named import (import { debounce } from 'some-lib') ships the whole package if that package publishes only CommonJS or forgets "sideEffects": false in its package.json. Webpack cannot statically prove which exports are unused across a CommonJS require graph, so the entire module survives minification — the same barrier described in the tree-shaking guide linked above, but here it silently inflates vendor rather than a feature chunk.

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.
};

The split alone is not enough to make the framework chunk actually cacheable. Two more settings finish the job: moduleIds: 'deterministic' freezes each module's internal id so an unrelated code change elsewhere does not renumber modules and shift the framework chunk's contents, and a [contenthash] filename ties the cache key to the bytes rather than to the build. Together they let a returning visitor keep the framework chunk in cache across dozens of deploys.

js
// webpack.config.js — make the stable chunk genuinely long-term cacheable
module.exports = {
  output: {
    filename: '[name].[contenthash].js', // hash follows content, not build number
  },
  optimization: {
    moduleIds: 'deterministic', // stable ids so unrelated edits don't reshuffle
  },
  // trade-off: contenthash filenames require hashed asset references (they are
  // fingerprinted), so you must serve them through the generated HTML/manifest —
  // never hardcode a chunk filename anywhere it could go stale.
};

Expected outcome: isolates ~45KB of stable framework code into a long-lived chunk; combined with deterministic ids, reduces the bytes re-downloaded per deploy by the framework's share (often ~30-40% of repeat-visit transfer), since the framework hash now survives builds that only touch application code.

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. A healthy result on a typical mid-size app looks like this: a single vendor.js of ~400KB gzipped that re-downloaded in full on every deploy becomes a stable framework chunk (~45KB, cached across deploys), a slim vendor chunk (~120KB), and one lazy dashboard chunk (~45KB) that never touches the marketing routes — dropping first-route JavaScript from ~400KB to ~165KB gzipped and the repeat-visit transfer far lower still.

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