DEV Community

Cover image for How I Cut a React App's Initial Bundle by 89%
Sourav Bhowmik
Sourav Bhowmik

Posted on

How I Cut a React App's Initial Bundle by 89%

Building and shipping applications is an integral part of a software developer's life. In this article I show you how I ran an experiment on the initial bundle size of a frontend web app build, specifically, which factors are responsible for bundle bloat, and how much of it can actually be reduced. My baseline build shipped a 634 KB (gzip) initial bundle. After running four optimization experiments, it dropped to 71 KB, a staggering 89% reduction!

Setup

I built a minimal activity-dashboard app in React, with help from Claude AI. The baseline version deliberately includes a few heavy dependencies: react-icons, lodash, recharts, and moment; each picked for a reason that seemed sensible at the time, not out of carelessness.

  • moment, because it's one of the most popular date libraries in JS and handling relative timestamps correctly (pluralization, day boundaries) felt like something worth not hand-rolling.
  • lodash, because I needed debounce for the search input and groupBy to cluster activities by date; pulling in a well-known utility library felt like the obvious choice. (There is a surprise bug for the lodash imports! Continue reading to find out 😉)
  • recharts, because the Dashboard page just needed one simple line chart, and it didn't seem worth reaching for something heavier like Chart.js.

The app has three pages: Feed (the landing page, showing dummy activity items), Dashboard (a line chart of weekly activity via recharts), and Settings (empty, included only to demonstrate route-based code splitting).

Feed page


Dashboard page

For measuring bundle composition, I used rollup-plugin-visualizer:

// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    react(),
    visualizer({ filename: 'dist/stats.html', gzipSize: true }),
  ],
})
Enter fullscreen mode Exit fullscreen mode

This generates a stats.html treemap after every build, showing exactly which libraries and functions are taking up space.

Experiments

I ran four experiments from the baseline, each isolated on its own branch, to measure the individual impact of a single fix on initial bundle size. A final branch combines all four.

1. Fixing the icon import

This was the single biggest win. The baseline imported the entire icon set:

import * as Icons from 'react-icons/fa'
Enter fullscreen mode Exit fullscreen mode

This looked necessary because the icon to render is resolved at runtime from a category field in the data — so it seemed like there was no way to know in advance which icons I'd need. But the category set is actually small and known ahead of time, so named imports work fine:

import { FaComment, FaUpload, FaAt, FaHeart, FaUserPlus } from 'react-icons/fa'

const CATEGORY_TO_ICON = {
  comment: FaComment,
  upload: FaUpload,
  mention: FaAt,
  like: FaHeart,
  join: FaUserPlus,
}
Enter fullscreen mode Exit fullscreen mode

This single change cut the initial bundle by 67% — from 634 KB to 209 KB (gzip).

2. Lazy-loading the Dashboard route

The Dashboard page's line chart (recharts) was being loaded on every page visit, even for users who never opened the Dashboard. Wrapping the route in React.lazy + Suspense defers that cost until someone actually navigates there:

// Before
import Dashboard from './pages/Dashboard'

// After
const Dashboard = lazy(() => import('./pages/Dashboard'))
Enter fullscreen mode Exit fullscreen mode
<Suspense fallback={<p>Loading...</p>}>
  <Routes>
    <Route path="/dashboard" element={<Dashboard />} />
    {/* ...other routes */}
  </Routes>
</Suspense>
Enter fullscreen mode Exit fullscreen mode

This reduced the initial bundle by 17%, from 634 KB to 529 KB (gzip).

Worth calling out: this fix doesn't make recharts disappear; it rather moves it into a separate chunk that loads on demand. So while the initial download shrinks by 17%, the total JS the app ships (initial + Dashboard chunk combined) barely changes. That distinction, initial load vs. total weight, turned out to matter a lot for how I read all four results, and especially the combined one at the end.

3. Fixing the lodash import

The baseline imported lodash like this:

import { debounce, groupBy } from 'lodash'
Enter fullscreen mode Exit fullscreen mode

This looks like a named import, but it isn't one; at least not in a way that helps. Lodash's main entry point is a CommonJS module, so the bundler can't statically determine which functions are actually used and ends up including the whole library anyway. It's effectively the same as:

import _ from 'lodash'
Enter fullscreen mode Exit fullscreen mode

The fix is to import each function from its own subpath:

import debounce from 'lodash/debounce'
import groupBy from 'lodash/groupby'
Enter fullscreen mode Exit fullscreen mode

This properly tree-shakes and cuts the bundle by 5%; a small number, but the mistake itself is the more useful finding: it's easy to write an import that looks tree-shaken and isn't.

N.B. There is a subtle and easy-to-miss bug in the second lodash import lodash/groupby. Named imports are case-sensitive, so the correct import path should be:

import groupBy from 'lodash/groupBy'
Enter fullscreen mode Exit fullscreen mode

Then how did this work on my machine? Because Windows and MacOS filesystems are not case-sensitive. So the import path worked on my Windows environment. But this would raise an error in a Linux environment.

Switching to lodash-es with named imports (import { debounce, groupBy } from 'lodash-es') will completely remove this bug and make things even easier. And since lodash-es is a proper ES Module, tree shaking is embedded by default.

4. Replacing moment with date-fns

moment is a large, feature-complete library, and reaching for it as a first instinct for date handling is common since it's intuitive and well-documented. But most of that surface area goes unused in any single app, and unlike modern alternatives, moment isn't built to tree-shake. Swapping it for date-fns, which ships as individual function modules:

// Before
moment(item.timestamp).fromNow()

// After
formatDistanceToNow(item.timestamp, { addSuffix: true })
Enter fullscreen mode Exit fullscreen mode

This produced the smallest change of the four — a 2% reduction. But in an app with heavier date usage than this one, that gap would likely be much larger; this app only touches three moment methods, so there wasn't much bulk to remove in the first place.

Combined result

Applying all four fixes together dropped the initial bundle from 634 KB to 71 KB (gzip) — an 89% reduction.

Before After Change
Initial download (what a first-time visitor gets) 634 KB 71 KB -89%
Total JS shipped (initial + Dashboard chunk) 634 KB 168 KB -74%

The gap between those two rows is the point of lazy-loading: total app weight didn't shrink as much as the initial load did, because the chart code still exists; it just isn't downloaded until someone actually needs it.

Takeaway

The goal here was to find out which kinds of bundle bloat actually matter, not just to list generic advice. Of the four fixes, the wildcard icon import alone accounted for a 67% reduction — more than the other three combined. If you're short on time, auditing wildcard/dynamic imports is probably the highest-leverage place to start.

It's also worth knowing that named imports aren't automatically tree-shaken. Instead, it depends on how the library itself is structured, and it's easy to get this wrong without realizing it, as the lodash example shows.

The full project, including each experiment on its own branch and the combined final version, is on GitHub: [https://github.com/bhowmik94/bundle-experiment]. The experiments/ folder has the bundle visualizer output for each build, showing the exact size breakdown per library and function, if you want to dig deeper. You are welcome to reproduce the same results and see for yourself. Just fork the project and have fun! Please feel free to leave any questions or comments below for further discussions and improvements. And if you are feeling generous, consider leaving a heart in this article.

Top comments (4)

Collapse
 
101beardo profile image
Tarun Sharma •

Good point about initial vs total weight, most write-ups skip that. One small thing in the lodash snippet: the path is case sensitive, so lodash/groupby will fail on a Linux CI build even if it resolves on a Mac, it should be lodash/groupBy. Did you also try lodash-es for that step? With a bundler like Vite the named imports tree-shake there, so you can keep the nicer syntax.

Collapse
 
sourav_bhowmik_73d35592ab profile image
Sourav Bhowmik •

Hello Tarun, great catch with the groupBy import from lodash. It should be lodash/groupBy. I did not try lodash-es import. That would make life even easier because of the automatic tree-shaking provided there. I kept the CommonJS module imports to emphasize the importance of named imports vs. wildcard imports.

Collapse
 
svgicons profile image
Svg/icons •

For larger data-driven icon sets, have you tried generating a manifest of explicit imports at build time? It keeps lookup dynamic while still giving the bundler statically analyzable dependencies.

Collapse
 
sourav_bhowmik_73d35592ab profile image
Sourav Bhowmik • • Edited

Thanks for your insightful comment. I totally agree with your suggestion. The named and explicit imports were deliberately kept that way to avoid complexity and to keep the article focused. But moving to creating a manifest JS file for dynamic lookup is the natural next step if the number of icons is very large.