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.
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].jsis 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.)
# 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.
// 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.
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.
// 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.
// 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 + vendorinitial 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.
// 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.
Related
- Webpack bundle analysis techniques — the parent guide on measuring what is inside your chunks.
- How to configure webpack bundle analyzer for production — generating the treemap this workflow depends on.
- Fixing tree-shaking issues with lodash and moment — eliminating the namespace-import bloat behind failure mode 2.
- Vite vs webpack bundle splitting performance — how chunking control compares across bundlers.
- Optimizing First Input Delay and INP — why smaller, split chunks reduce parse-driven input delay.