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 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 debounce for the search input and groupBy to 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).

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.

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. Please feel free to leave any questions or comments below for further discussions and improvements.

Top comments (0)