Every kilobyte you ship is a tax on your users. React apps start lean, then slowly bloat under dependencies, lazy patterns, and forgotten imports. The goal is simple: deliver the smallest bundle you can while keeping every feature you built. This isn’t about gutting the UI. It’s about knowing what actually lands in the browser and making hard, surgical cuts. Let’s get into the weeds.

First, Measure What Hurts
You can’t fix what you can’t see. Before touching a single import, generate a production build and analyze it. The tools are free and fast. source-map-explorer and webpack-bundle-analyzer spit out treemaps that expose the heaviest chunks. One chart can reveal a 500KB icon library where you only use three glyphs. Or a date utility dragging in every locale set under the sun. Run the analyzer, sort by size, and pick the top five offenders. That’s your hit list.
Tree Shaking: More Than Just a Buzzword
Tree shaking depends on ES module static structure. If your dependencies use CommonJS, the bundler can’t eliminate dead code. Verify that your libraries expose ES module builds—usually a module or es2015 field in package.json. Even with ES modules, you can sabotage tree shaking by using default imports that grab everything. Prefer named imports: import { debounce } from 'lodash-es' instead of import _ from 'lodash'. Check your own code too. A barrel export file that re-exports 40 components but you only use two? It still pulls all 40 into the dependency graph. Break those barrels or use direct file imports for critical paths.

Code Splitting With Intent
React.lazy and Suspense are standard, but lazy loading everything is a mistake. Over-splitting creates a waterfall of network requests and janky transitions. Profile your routes and pick out what’s below the fold or behind a user action. A heavy charting library used only on the analytics dashboard? Perfect candidate. A modal that appears on 90% of sessions? Keep it in the main bundle. The mental model: split at the route level, then split for heavy, conditionally rendered components.
Dynamic Imports and Prefetching Tactics
A vanilla dynamic import fetches the chunk when the component renders. That’s too late for a button click. Add a prefetch hint. Webpack supports /* webpackPrefetch: true */ inside the import statement. It tells the browser to grab the chunk during idle time. For even more control, use IntersectionObserver to prefetch a chunk when a link scrolls into view. The user gets the feature instantly, and you didn’t bloat the initial download. Just don’t prefetch a dozen heavy chunks—you’ll saturate the network and hurt the main thread.
Dependency Audits: Kill What You Don’t Need
Node_modules is a graveyard of “I might use this later” packages. Run depcheck to find unused dependencies. But the real savings come from scrutinizing the ones you do use. Moment.js is the classic example. It packs locale data you’ll never touch. Replace it with date-fns or Day.js, both built for tree shaking. Even lodash can be swapped for native array methods or the modular lodash-es. For every dependency, ask: does this package do one thing, or does it do fifty things I ignore? If the latter, find a focused alternative.
Sharing Code With Module Federation
If you maintain multiple React apps in a monorepo, you’re probably shipping duplicate code. Module Federation, introduced in Webpack 5, lets you share vendor chunks at runtime. Define a shared scope for React, React-DOM, and common utilities. The browser downloads them once and reuses them across all micro-frontends. Configuration is exacting, and version mismatches can break things, so pin exact versions and test thoroughly. The payoff is a dramatic drop in total bytes across your ecosystem.

Compression and Modern Output Targets
Build size is one number; transferred size is what matters. Enable Brotli compression on your server or CDN. It’s consistently smaller than gzip for JavaScript assets. Next, configure your bundler to output differential serving. Emit ES2017+ bundles for modern browsers and a legacy fallback for the rest. The modern bundle skips transpilation of classes, async/await, and arrow functions. That means less code and faster parsing. Tools like @babel/preset-env with targets and useBuiltIns plus a module/nomodule pattern make this straightforward.
Practical Babel and Terser Tuning
Babel’s preset-env injects polyfills based on your browser list. If you target only modern browsers, you can drop the polyfills for Promises and Array.includes. Set useBuiltIns: 'usage' and corejs: 3, and Babel adds only what’s needed. On the minifier side, Terser’s default settings are conservative. Crank up compress.passes to 2 or 3, and enable toplevel to eliminate unused top-level functions and variables. These tweaks can shave another 5–10% off an already minified file. Always test your app thoroughly after adjusting these—aggressive compression can mangle code that relies on function names or property access patterns.
Component-Level Micro-Optimizations
Sometimes the bloat is in your own code. A single component that imports a heavy utility for a rare edge case drags that utility into every consumer. Use lazy initialization inside event handlers: const handleClick = async () => { const lib = await import('heavy-lib'); lib.doSomething(); }. This moves the cost to the point of interaction. Similarly, if a component uses a context that holds a large object, every render of every consumer triggers a reconciliation. Split contexts by value type, and memoize selectors with useMemo or libraries like zustand that allow precise subscriptions.
CSS and Asset Sanity
Bundle size isn’t just JavaScript. CSS-in-JS libraries can generate megabytes of runtime overhead. If you’re using a runtime solution, consider extracting static styles at build time with Linaria or vanilla-extract. The styles become plain CSS files, and the JavaScript runtime disappears. For images, never bundle them into JavaScript. Use loaders that emit separate files and let the browser’s cache do its job. Lazy load images below the fold with native loading="lazy". An SVG icon sprite used across the app? Instead of inlining it everywhere, load it once as an external resource and reference it with <use>.
FAQ
How do I know if my tree shaking is actually working?
Generate a production build and run it through webpack-bundle-analyzer or source-map-explorer. Look for large, unused exports inside vendor chunks. If you see functions or classes you never imported, tree shaking failed. Check that the library provides an ES module entry point and that you’re using named imports. You can also temporarily delete the import, rebuild, and see if the chunk size drops. No drop means the code was never included—or it was included through another dependency.
Will code splitting make my app feel slower to navigate?
It can, if you do it without a plan. Lazy loading a route shows a loading indicator while the chunk downloads. On slow connections, that’s worse than a slightly larger initial bundle that loads once. The fix is to prefetch chunks for likely next actions. Use webpack’s magic comments or <link rel="prefetch"> tags. Measure Interaction to Next Paint (INP) in real-user monitoring to confirm that splits are helping, not hurting perceived speed.
What’s the quickest win for a bloated React project?
Audit your third-party dependencies first. Run npx depcheck to remove unused packages. Then swap moment.js for date-fns or Day.js, and replace lodash with native methods or lodash-es. These two changes alone often cut 50–100KB from a gzipped bundle. After that, configure your bundler to output ES2017+ modules with differential serving. You’ll see an immediate reduction without touching a single component.
Does using React.memo everywhere reduce bundle size?
No. React.memo prevents re-renders, which reduces CPU work, not bundle size. In fact, wrapping every component adds a tiny bit of code and can increase bundle footprint—negligibly, but still. Focus React.memo on components that receive stable props but re-render often due to a parent update. Use the React DevTools Profiler to find actual re-render bottlenecks, not guesswork.