By Suki Watanabe
You just ran the production build, and that bundle size number is glaring at you like a traffic light stuck on red. 300 kB, 500 kB, maybe more. You know every kilobyte counts for load time, but cutting features feels like amputating a limb to lose weight. I’ve been in that exact spot, staring at a dashboard after a user complained about a 6-second load on 3G. The good news? You can shrink that bundle without turning your app into a skeleton. The bad news? It takes sweat, not a magic plugin. Here’s exactly how I do it, step by step, with zero hand-waving.

1. Start With a Hard Look at Your Dependencies
Most bloated bundles I see aren’t from custom code—they’re from third-party libraries. You add a date picker, a charting tool, a utility library, and suddenly you’re shipping half of npm to the browser. My rule: treat every dependency like a tenant you have to evict if they don’t pay rent. Use bundlephobia.com to check the cost before adding anything. Then run a quick audit:
npx source-map-explorer build/static/js/*.js
This generates a treemap that shows exactly which modules are eating your bytes. In one project, I found that a single moment.js import was pulling in 230 kB of locale data nobody used. Replacing it with date-fns and tree-shaking cut 180 kB instantly. Another time, lodash was imported as import _ from 'lodash' instead of import debounce from 'lodash/debounce'—a classic mistake that drags in the whole library. Fix those first. You can’t optimize what you don’t measure.
2. Code Splitting That Actually Works
React’s lazy and Suspense are not decorations; they’re your primary weapon. The default approach of dumping everything into a single main.chunk.js is like forcing every shopper to walk through the entire mall before buying a coffee. Route-based splitting is table stakes:
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));
Wrap those in Suspense with a fallback spinner, and now users only download the code for the page they visit. But don’t stop there. I split at the component level for heavy, non-critical pieces. A modal with a form? Lazy load it. A chart that appears below the fold? Lazy load it. One e-commerce site I worked on had a product recommendation carousel using a 90 kB library. Lazy loading just that carousel shaved 90 kB off the initial bundle. But—and this is important—don’t over-split. Too many tiny chunks create network waterfall and hurt performance. Use the Coverage tab in Chrome DevTools to see what code actually runs on load, and split only what isn’t needed immediately.

3. Tree Shaking Is a Promise, Not a Guarantee
Webpack and Rollup advertise tree shaking, but it fails silently more often than developers admit. Tree shaking only removes dead code when you use ES modules (import/export) and the library author set the sideEffects flag in its package.json. If a library uses CommonJS (require), the bundler can’t safely prune it. Check your dependencies: if you see require in the source, that library is a rock in your bag. For example, many older React component libraries still ship CommonJS. Switch to alternatives that offer ES module builds, or use plugins like babel-plugin-import for libraries like Ant Design that support on-demand loading.
Also, verify your own code. If you have a utils file exporting 50 functions but only 3 are imported, the bundler should drop the rest—if your Babel config isn’t transpiling ES modules to CommonJS. In Create React App, this is handled, but custom setups often mess it up. Check your webpack.config.js for modules: false in the Babel preset-env options. I once spent an afternoon debugging why tree shaking wasn’t working, only to find a single rogue @babel/plugin-transform-modules-commonjs in the config. Removing it dropped 40 kB.
4. Images and Assets: The Silent Killers
JavaScript isn’t the only thing ballooning your bundle. A 2 MB hero image loaded via import will end up base64-encoded inside your JS, blowing up parse time. Keep images out of the bundle entirely. Serve them from a CDN or public/ folder, and use lazy loading with loading="lazy" or a library like react-lazyload. For small icons, inline SVGs as React components—they’re tiny and don’t trigger extra network requests. But avoid importing huge SVG libraries like Font Awesome in full; use their tree-shakable packages or cherry-pick icons manually.
Catch asset inflation early with Webpack’s performance hints. Set a max asset size in your config:
performance: {
maxAssetSize: 100000,
hints: 'error'
}
This flags anything over 100 kB during the build. I’ve caught accidental 500 kB PNG imports this way more times than I can count.
5. Production Build Settings You’re Probably Ignoring
Your development build is a padded cell designed for debugging. Your production build should be a stripped-down machine. Start with these Webpack optimizations—they’re not defaults, and many teams skip them:
- Minification:
TerserPluginis standard, but crank up its settings. Enablecompress.drop_console: trueto stripconsole.log(keepconsole.errorandconsole.warnif you need them). Addcompress.passes: 2for more aggressive dead-code removal. - Gzip/Brotli: Your server should serve compressed assets. Use
CompressionPluginto pre-generate.gzand.brfiles during the build. This isn’t a bundle size reduction, but it slashes transfer size by 70% or more. - Module concatenation:
ModuleConcatenationPlugin(scope hoisting) is on by default in production mode, but verify it isn’t disabled. It inlines modules where possible, reducing function wrappers. - DefinePlugin: Replace
process.env.NODE_ENVwith'production'so React drops development warnings and prop-types. Miss this, and you’re shipping extra kilobytes for no reason.
In one audit, I found a project that had mode: 'development' in their production CI pipeline. No minification, no tree shaking, every React warning intact. Fixing that dropped the bundle by 60% in one commit.

6. Replace Heavy Libraries With Native APIs
JavaScript and browsers have grown up. Many utilities you import can be replaced with built-in methods that are faster and free in terms of bundle bytes. Some direct swaps I’ve made:
- Axios → Fetch: Axios is 13 kB gzipped.
fetchis built in. Write a thin wrapper if you need interceptors, but most apps don’t. - Lodash → vanilla JS:
_.get()can be optional chaining (obj?.a?.b)._.debounce()is a 10-line function you can write yourself. The entire lodash library is 70 kB gzipped. - Moment.js → date-fns or Intl:
Intl.DateTimeFormathandles formatting without a library.date-fnsis modular and tree-shakable. - Classnames → template literals: The
classnamespackage is tiny, but a simple conditional string works:className={`btn ${isActive ? 'btn-active' : ''}`}.
Before replacing anything, check the cost with Bundlephobia and test the native alternative. It’s not always a 1:1 swap—Intl support varies in older browsers—but the savings add up fast.
7. Monitor Bundle Size in CI
You can’t rely on developers to manually check bundle size. It’s too easy to merge a PR that adds 50 kB of a new charting library. Set up automated checks. I use bundlesize in the CI pipeline with a config like:
{
"files": [
{
"path": "./build/static/js/*.js",
"maxSize": "200 kB"
}
]
}
If the build exceeds the limit, the CI fails. Pair this with webpack-bundle-analyzer to generate a report on each PR, so reviewers can see exactly what changed. Make it a rule: any new dependency over 10 kB gzipped requires explicit justification in the PR description. This isn’t bureaucracy; it’s defense. In a team I led, this single rule prevented four unnecessary libraries in three months.
8. Use Differential Serving (Modern vs. Legacy Bundles)
Not all browsers need the same code. Modern browsers support ES modules natively, while older ones need transpiled fallbacks. With differential serving, you generate two bundles: a lean ES2015+ bundle for modern browsers and a transpiled ES5 bundle for legacy. The browser loads only what it needs via <script type="module"> and <script nomodule>. Tools like @babel/preset-env with targets.esmodules make this straightforward. In Create React App, this is experimental but can be enabled. In custom setups, configure Webpack to output two builds. One site I worked on saw a 20% reduction in JavaScript shipped to Chrome and Firefox users—a huge win for the majority of traffic.
9. CSS Is Part of the Bundle Story
Your JS bundle often includes CSS, either via CSS-in-JS or imported stylesheets. That CSS can be massive. I’ve seen a single MUI theme add 50 kB of unused classes. Use PurgeCSS to scan your components and remove unused selectors. If you use CSS Modules or styled-components, the dead-code elimination is built-in, but still audit the output. For global styles, avoid importing entire reset libraries like Normalize.css (8 kB) when a minimal reset (a few rules) does the job. Also, extract CSS into separate files so it can be cached independently—Webpack’s MiniCssExtractPlugin handles this. Don’t inline critical CSS into JS unless it’s tiny; the extra parse time usually outweighs the benefit.
10. Real-World Win: A Case Study
Let me ground this in a concrete example. A client had a React dashboard with a 1.2 MB initial bundle (280 kB gzipped). The app used Ant Design, Moment.js, Lodash, and a few chart libraries. Here’s the playbook we ran:
- Audit with source-map-explorer: Moment.js and Ant Design icons were the top two offenders.
- Replace Moment.js with date-fns: Added babel-plugin-import for date-fns to enable tree shaking. Savings: 180 kB unzipped.
- Switch to on-demand Ant Design imports: Used
babel-plugin-importto pull only used components. Savings: 120 kB unzipped. - Lazy-load routes and heavy widgets: Split the dashboard into separate chunks for each tab. Savings: 200 kB from initial load.
- Strip console.log and dead code: TerserPlugin config changes. Savings: 30 kB.
- Serve images from CDN: Moved 5 PNGs out of the bundle. Savings: 400 kB unzipped.
End result: initial bundle dropped to 380 kB unzipped (95 kB gzipped), and Time to Interactive went from 4.8s to 1.9s on 3G. No features were removed. The dashboard still had charts, date pickers, and the same Ant Design components. The only difference was intentional loading and dead-code removal.
FAQ
How do I know if my tree shaking is working?
Use webpack-bundle-analyzer to generate a visual report. Look for modules that are fully included despite only partial use, like an entire lodash build. Then check the library’s package.json for a module field (points to ES module entry) and sideEffects: false. If those are missing, tree shaking will likely fail. You can also add a temporary console.log in the unused code and check if it appears in the production build—quick and dirty but effective.
Is code splitting always beneficial for performance?
No. Code splitting reduces the initial download size, but too many small chunks can cause network congestion and slow down navigation due to multiple round trips. Each chunk also has a small Webpack runtime overhead. A good rule of thumb: split at the route level always; split component-level only if the component is over 30 kB gzipped and not needed for the initial render. Measure with Lighthouse to find the sweet spot.
What’s the fastest way to trim 100 kB from a React bundle right now?
Check for Moment.js or Lodash imported without tree shaking. Replacing Moment.js with date-fns and using direct imports for Lodash often drops 100–200 kB instantly. Next, ensure your production build has NODE_ENV=production and minification enabled. These two steps can be done in under an hour and usually yield the biggest wins.
How do I stop developers from adding heavy libraries without oversight?
Add bundlesize to your CI pipeline with a strict max size. Configure it to fail the build if the bundle grows beyond a threshold. Also, use a dependency budget tool like size-limit or webpack-bundle-analyzer in PR checks, and require approval for any new dependency over 10 kB gzipped. Make bundle size visible in every pull request so it’s part of the team’s daily consciousness.










