Home / Insights / Development

Cutting a React bundle in half without rewriting it

Where the weight actually is on a typical dashboard, measured on a real project, with the four changes that did most of the work.

Taha Tahir, Technical Lead 7 min read

A client dashboard was shipping 1.4 MB of JavaScript, gzipped. On the office fibre connection it felt fine. On a sales rep's phone in a warehouse it took eleven seconds before anything appeared.

We got it to 620 KB in two days without touching a single component. Here is where the weight was, which is almost never where people assume.

Measure before guessing

Install the bundle visualiser and look at the treemap. Not optional — every optimisation instinct you have is about to be contradicted by it.

npm install --save-dev rollup-plugin-visualizer

On this project, React and React DOM were 45 KB together. Everyone had assumed the framework was the problem. It was 3% of the bundle.

1 · The date library — 280 KB

Moment.js, imported for one function that formatted "3 days ago", and bundling every locale on earth.

Replaced with date-fns, importing only the two functions actually used. Tree-shaking handles the rest.

// before — the whole library, every locale
import moment from 'moment';

// after — two functions
import { formatDistanceToNow, format } from 'date-fns';

Saved 258 KB. Two hours of work including testing every date display.

2 · Icons imported as a barrel — 190 KB

This one catches everybody:

import { Search, User, Settings } from 'react-icons/fa';

Looks like three icons. Depending on the library and your bundler settings, it can pull the entire set, because the barrel file re-exports thousands of modules and the bundler cannot always prove the rest are unused.

Import from the exact path, or move to a set that ships each icon as its own module. We did the latter.

Saved 176 KB. One hour, almost all of it find-and-replace.

3 · The chart library loaded on every page — 240 KB

Charts appeared on two routes out of fourteen, and loaded on all fourteen.

const Charts = lazy(() => import('./Charts'));

<Suspense fallback={<ChartSkeleton />}>
  <Charts data={data} />
</Suspense>

Now it downloads when somebody opens a page with a chart on it. The skeleton matters — without a placeholder of the right height, the layout jumps when the chunk arrives, and you have traded a loading problem for a stability problem.

Saved 240 KB from the initial load. Three hours, mostly on the skeletons.

4 · Lodash, the whole thing — 70 KB

Imported for debounce, groupBy and uniq.

uniq is [...new Set(arr)]. groupBy is six lines. debounce is worth keeping, so we imported just that one:

import debounce from 'lodash/debounce';

Saved 66 KB. One hour.

The result

  • Bundle: 1.4 MB → 620 KB gzipped
  • Time to interactive on a mid-range Android over 3G: 11.2s → 4.1s
  • Components changed: zero
  • Total time: about two days including testing

What we deliberately did not do

We did not replace React with a smaller framework. We did not rewrite components to be cleverer. We did not micro-optimise render counts.

All of those are real techniques and none of them were the problem. The problem was four dependencies doing far more than was asked of them, and the fix was import discipline rather than engineering effort.

Before anyone proposes a rewrite for performance, make them show you the treemap. Most of the time the answer is a day of import changes, and the rewrite was never needed.

Working on something like this? We do this kind of work for a living — software, design, delivery and the accounting behind it.

Start a conversation