Trim the Fat: React Bundle Fixes That Don’t Gut Your Features

React apps always start the same way—lean and snappy. Then some well-meaning dev adds a date picker, a charting library, and a few icon packs. Next thing you know, the bundle’s ballooned past 500 KB before you’ve even typed your first useEffect. The usual refrain is “code split everything,” but honestly, that’s skipping a step. First you should cut the crap that shouldn’t be shipping at all. Then you split what’s left. Here’s how to do that without stripping features your users actually care about.

Know What’s Actually Shipping Before You Slice Anything

Guessing where the bloat hides is a fool’s errand. Do a build with source-map analysis switched on and stare at the raw numbers. Webpack’s webpack-bundle-analyzer spits out a treemap; each rectangle is a module. You’ll spot things that make you grimace—a 120 KB locale file for a date library you only ever use in English, or a full icon set dragged in because you needed three little icons. Ouch.

Developer analyzing code on screen

Set a hard budget. If your app’s initial JavaScript payload tips past 200 KB gzipped, treat it like a bug. CI tools like bundlesize will slam a PR shut if someone sneaks in 10 KB of junk dependency. The exact number matters less than the direction. When the bundle graph spikes upward, somebody made a call you need to know about, fast.

Tree Shaking: Not a Magic Wand

People treat tree shaking like a box to tick—“Oh, we use ES modules, we’re fine.” It only works if the library author didn’t bungle their exports and you steer clear of side-effect-heavy imports. Take lodash: a simple import { debounce } from 'lodash' drags the whole beast in, unless you switch to lodash-es or do a deep import like import debounce from 'lodash/debounce'. Even then, your Babel or TypeScript settings can quietly undo your effort. Double-check the sideEffects flag in package.json and confirm with the analyzer that dead code actually vanishes from the output.

Moment.js is the poster child for this mess. If your app still clings to it, swapping for date-fns or dayjs often lops off 50–70 KB instantly. Those libraries don’t force-feed you every locale and timezone under the sun.

Code Splitting That Mirrors How People Use Your App

Route-based splitting with React.lazy is the no-brainer first move. But plenty of apps lug around chunky components that aren’t tied to a route: a rich text editor, a data grid, a video player. Wrap those with dynamic imports that fire on user interaction, not on mount. Got a modal that houses a chart library? Only fetch the chart code when someone clicks the button to open it. That pattern—loading on intent—can shave 100–200 KB off the initial download and nobody notices a hiccup.

Code splitting illustration with network graph

const HeavyChart = React.lazy(() => import('./HeavyChart'));

function Dashboard() {
  const [showChart, setShowChart] = useState(false);
  return (
    <>
      <button onClick={() => setShowChart(true)}>View Analytics</button>
      {showChart && (
        <Suspense fallback={<Spinner />}>
          <HeavyChart />
        </Suspense>
      )}
    </>
  );
}

Don’t sleep on shared chunks either. Two lazy-loaded routes that both lean on a fat utility library? Webpack might duplicate it. Tweak splitChunks to yank common dependencies into a vendor chunk that caches on its own. A 40 KB library repeated across three chunks balloons into 120 KB of wasted download. One cached vendor chunk kills that problem dead.

Dependency Audits: Quarterly, Not Once a Decade

Every dependency you add is a maintenance pact. Before you install a package, punch its name into bundlephobia and see what it costs. A tiny utility that does one thing shouldn’t carry a 15 KB gzipped price tag; you can often write the damn thing yourself in 20 lines. A basic classnames alternative, if you just need conditional class joining, clocks in under 200 bytes:

function cn(...classes) {
  return classes.filter(Boolean).join(' ');
}

When a package is non-negotiable, pin its version and set a calendar reminder to re-evaluate every three months. Library APIs shift, and your app’s needs shift. That animation library you tossed in for a one-off onboarding flow? Might be dead weight next quarter.

Image and Font Strategy: Inside the Bundle Trenches

Images shouldn’t even be in the JavaScript bundle, but small inline SVGs creep in. If you’re inlining more than a handful of icons, move to an SVG sprite served as a static asset, or use a font-based icon set that tree shakes properly. react-icons lets you import only what you need, but verify your bundler isn’t stuffing the whole library in anyway. Spot thousands of SVG paths in the analyzer? You’ve messed up.

SVG icons displayed on a grid

Fonts are another stealth tax. A single variable font file at 30 KB can replace three or four individual weight files that together weigh 120 KB. If you only need regular and bold, subset the font down to Latin characters and drop the extra weights. Tools like glyphhanger generate subsets from the actual characters in your build output, not your best guess.

State Management That Doesn’t Balloon Out of Control

Redux adds 10–15 KB even after tree shaking, and then there’s middleware. If your app uses it for a single scrap of global state, ask yourself if the Context API or a featherweight alternative like Zustand (under 1 KB) could handle it. Zustand’s API is close enough to Redux’s that switching often feels mechanical, and you ditch the heavy middleware chain. One team I worked with dropped 9 KB by swapping Redux for Zustand—didn’t alter a single component’s behavior.

For server state, React Query or SWR take care of caching, refetching, and deduplication without forcing you to micromanage loading states. They also wipe out the need for a separate state layer for API data, which frequently means deleting entire reducer files and their tests.

Lazy Hydration and Partial Rehydration

If you’re on a framework like Next.js with server-side rendering, the whole page hydrates on load—even chunks that don’t need any interactivity. A footer full of static links, a hero section that never changes. These don’t need JavaScript. Use react-lite or react-lazy-hydration to defer or skip hydration for static sections. The browser parses the HTML, shows it, and never lets React’s reconciliation touch those DOM nodes. On a content-heavy marketing site, this can shrink the hydration phase by 30–40% because React isn’t trudging through the entire tree.

import LazyHydrate from 'react-lazy-hydration';

function Page() {
  return (
    <>
      <LazyHydrate whenVisible>
        <Footer />
      </LazyHydrate>
    </>
  );
}

Pin your performance budgets to user metrics, not just file sizes. Keep an eye on Time to Interactive and First Input Delay. A 150 KB bundle that parses in 200 ms on a mid-range phone? Totally fine. A 100 KB bundle with a 50 KB polyfill that hogs the main thread? Not fine. Lighthouse scores are a rough proxy, not the finish line.

Polyfills and Transpilation Targets

Check your Browserslist config. If you’re still transpiling for IE11 in 2025, you’re shipping thousands of bytes of polyfills for features 99% of your users already have natively. Set "browserslist": ["> 0.5%", "not dead", "not ie 11"] and make sure Babel and Autoprefixer stop generating needless code. The @babel/preset-env useBuiltIns: 'usage' option scans your code and includes only the polyfills you actually touch, instead of the whole core-js library. That move alone can save 20–30 KB.

For async/await and generators, ask yourself if you genuinely need the regenerator runtime. Modern browsers handle these natively. If your audience runs on evergreen browsers, drop the regenerator transform and let the native engines do the work. The runtime’s about 6 KB minified—pure dead weight in a Chrome-only internal tool.

FAQ

How do I know if my bundle size is actually a problem?

Measure Time to Interactive on a throttled 3G connection with Lighthouse or WebPageTest. If TTI creeps past 3 seconds on a mid-range device (think Moto G4), your bundle’s a problem, no matter what the raw kilobyte count says. Also peek at the “Coverage” tab in Chrome DevTools: if 60% or more of your shipped JavaScript sits unused on the first page load, you’re making users download and parse code they never run.

Can I optimize a Create React App project without ejecting?

Yes, up to a point. CRA hides the webpack config, so you can’t fine-tune splitChunks or swap minifiers. You can still use React.lazy for code splitting, swap out heavy dependencies, and set a browserslist in package.json to trim polyfills. For deeper control, tools like craco or react-app-rewired let you override configs without ejecting, but they carry maintenance risk. If you keep smacking into CRA’s limits, migrating to Vite or Next.js gives you direct config access without the boilerplate headache.

What’s the fastest way to cut 50 KB from a typical React app?

Audit your icon imports first. If you’re using a package that vomits out thousands of icons, switch to individual imports or an SVG sprite. Next, ditch Moment.js for date-fns or dayjs. Those two changes alone often drop 50–80 KB. After that, hunt for duplicate dependencies in your lockfile with yarn why <package> or npm ls <package>. A single duplicated version of a 20 KB library wastes real bytes your users pay for on every page load.

Optimization isn’t about chasing perfection. It’s about shedding the weight your users shouldn’t have to lug around. Start with the fattest rectangle in the bundle analyzer, slice it out, and watch your metrics shift.