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": falsein itspackage.json. CommonJS-only libraries cannot be shaken — prefer the ESM build (often published as the-esvariant) 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 dependency | Lighter alternative | Typical savings (gzip) |
|---|---|---|
moment | date-fns or native Intl | ~60 kB |
lodash | lodash-es (per-method) or native | ~20–60 kB |
axios | native fetch | ~12 kB |
chart.js + plugins | lazy-loaded on demand | deferred 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 (
lodashtolodash-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.