How I Cut a React App's Initial Bundle by 89%!
Building and shipping applications is an integral part of the life of a software developer. In this article I show you how I ran an experiment on the initial bundle size of a frontend web app build, specifically, which f
Building and shipping applications is an integral part of the life of a software developer. 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
debouncefor the search input andgroupByto cluster activities by date; pulling in a well-known utility library felt like the obvious choice. - 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).
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 }),
],
})
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'
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,
}
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'))
<Suspense fallback={<p>Loading...</p>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
{/* ...other routes */}
</Routes>
</Suspense>
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'
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'
The fix is to import each function from its own subpath:
import debounce from 'lodash/debounce'
import groupBy from 'lodash/groupby'
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.
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 })
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. Please feel free to leave any questions or comments below for further discussions and improvements.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.
