Skip to content
React rc performance 4 min read

Reducing Bundle Size

A smaller JavaScript bundle means faster downloads, quicker parsing, and a snappier first interaction — especially on slow networks and low-end phones. While code splitting defers chunks until they are needed, reducing bundle size is about shipping less code overall: importing only what you use, choosing lean dependencies, and removing dead weight. The work pays off compounding interest, because every byte you cut is a byte every user never has to fetch again.

Measure before you optimize

The first rule is to never guess. Install a bundle visualizer and look at what actually ships before changing anything — intuition about what is “heavy” is frequently wrong. Vite builds with Rollup, so rollup-plugin-visualizer is the natural choice.

npm install -D rollup-plugin-visualizer
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    react(),
    visualizer({ open: true, gzipSize: true, brotliSize: true }),
  ],
});

Running the production build now opens an interactive treemap showing exactly which modules dominate the bundle.

npx vite build

Output:

dist/assets/index-a1b2c3.js     312.4 kB │ gzip: 98.7 kB
✓ built in 2.11s
stats.html generated — opening in browser

If a single library accounts for a large rectangle, that is your highest-leverage target.

Use tree-shakable named imports

Tree-shaking is the bundler’s ability to drop code you never reference, but it only works on ES modules with side-effect-free named exports. The classic mistake is importing a whole library namespace when you need one function.

// Bad — may pull the entire library into your bundle.
import _ from 'lodash';
const unique = _.uniq(items);

// Good — modern bundlers can shake away everything but uniq.
import uniq from 'lodash-es/uniq';
const result = uniq(items);

The same applies to icon and component libraries. Import individual members by name rather than re-exporting barrels, so unused members are eliminated.

// Imports only the icons you reference.
import { Search, Settings } from 'lucide-react';

function Toolbar() {
  return (
    <div className="toolbar">
      <Search size={18} />
      <Settings size={18} />
    </div>
  );
}

Tip: Tree-shaking depends on a package marking itself side-effect-free via "sideEffects": false in its package.json. CommonJS-only libraries cannot be shaken — prefer the ESM build (often published as the -es variant) when one exists.

Audit and replace heavy dependencies

Many bundles are dominated by one or two oversized packages that have lighter alternatives. Once the visualizer points you at a culprit, look for a smaller substitute that covers your actual use case.

Heavy dependencyLighter alternativeTypical savings (gzip)
momentdate-fns or native Intl~60 kB
lodashlodash-es (per-method) or native~20–60 kB
axiosnative fetch~12 kB
chart.js + pluginslazy-loaded on demanddeferred entirely

Native platform APIs are often the best “dependency” of all — zero bytes shipped.

// Replacing moment with the built-in Intl API: no dependency at all.
function formatDate(value) {
  return new Intl.DateTimeFormat('en-US', {
    dateStyle: 'medium',
  }).format(new Date(value));
}

function ReceiptDate({ iso }) {
  return <time dateTime={iso}>{formatDate(iso)}</time>;
}

Before adding any new package, check its weight on a tool like Bundlephobia and confirm it ships an ESM build.

Defer what you cannot remove

Some heavy modules are genuinely required but only by a fraction of users — a charting library, a markdown editor, a PDF viewer. Rather than removing them, move them out of the initial bundle with a dynamic import() so they load only when actually used.

import { lazy, Suspense } from 'react';

// Charting code becomes its own chunk, fetched on demand.
const SalesChart = lazy(() => import('./SalesChart'));

function Dashboard({ showChart }) {
  return (
    <section>
      <h1>Dashboard</h1>
      {showChart && (
        <Suspense fallback={<p>Loading chart…</p>}>
          <SalesChart />
        </Suspense>
      )}
    </section>
  );
}

This keeps the heavy dependency off the critical path without rewriting any logic. See the dedicated code-splitting guide for routing and preloading patterns.

A practical checklist

Work through these in order — each step is cheap and the early ones usually deliver the biggest wins.

  • Run the visualizer and record the current gzipped size as a baseline.
  • Replace namespace imports (import * as) with named imports so tree-shaking can work.
  • Swap any CommonJS-only library for its ESM build (lodash to lodash-es).
  • Replace oversized utilities with native APIs (fetch, Intl, URLSearchParams).
  • Lazy-load any dependency used by a minority of users.
  • Re-run the build and confirm the numbers actually dropped.

Best Practices

  • Always measure with a visualizer before and after; let real gzip sizes — not hunches — guide your changes.
  • Prefer named imports from ESM packages so the bundler can shake away unused code.
  • Reach for native browser APIs before adding a dependency; the lightest package is no package.
  • Vet new dependencies for size and ESM support before installing them.
  • Keep a single heavy widget out of the entry bundle by lazy-loading it.
  • Set a bundle-size budget in CI so regressions are caught automatically on every pull request.
  • Re-audit after major dependency upgrades, since a minor version bump can quietly bloat your output.
Last updated June 14, 2026
Was this helpful?