Why Your Bundle Is Still Huge (And How to Actually Fix It)
Last month I audited a React app that was shipping 1.2MB of JavaScript. The team had "optimized" it multiple times. Code splitting. Tree shaking. Lazy loading. It was still huge.
After a week of digging, I got it down to 380KB. Not by doing anything magic—by understanding where the bloat actually comes from.
Here's everything I learned.
Step 1: See What's Actually in There
You can't fix what you can't see. Before touching any code, analyze your bundle.
For Webpack:
npm install --save-dev webpack-bundle-analyzer// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
]
};For Vite:
npm install --save-dev rollup-plugin-visualizer// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({ open: true })
]
};For Next.js:
npm install --save-dev @next/bundle-analyzer// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// your config
});
// Then: ANALYZE=true npm run buildRun the analyzer and stare at that treemap. The big rectangles are your problems.
The Usual Suspects
In my experience, bundle bloat comes from a predictable set of culprits:
1. Moment.js (and its locales)
Moment.js is 300KB with locales. Three hundred kilobytes. For date formatting.
import moment from 'moment'; // 300KB ❌The fix:
// Option 1: Use date-fns (30KB, tree-shakeable)
import { format, parseISO } from 'date-fns';
// Option 2: Use dayjs (2KB moment API-compatible)
import dayjs from 'dayjs';
// Option 3: Use native Intl (0KB, built into browsers)
new Intl.DateTimeFormat('en-US').format(new Date());If you must use Moment, at least strip the locales:
// webpack.config.js
const webpack = require('webpack');
module.exports = {
plugins: [
new webpack.IgnorePlugin({
resourceRegExp: /^\.\/locale$/,
contextRegExp: /moment$/,
}),
],
};That drops it from 300KB to ~70KB.
2. Lodash
Lodash is well-designed but terribly for tree shaking. Importing the whole library brings in everything:
import _ from 'lodash'; // 70KB ❌
_.get(obj, 'nested.path');Fix:
// Import specific functions
import get from 'lodash/get'; // 2KB ✅
// Or use lodash-es for tree shaking
import { get } from 'lodash-es';Better yet, replace Lodash with native methods. Most of what people use Lodash for is built into JavaScript now:
// Lodash
_.map(arr, fn)
_.filter(arr, fn)
_.find(arr, fn)
_.includes(arr, item)
_.uniq(arr)
// Native JavaScript
arr.map(fn)
arr.filter(fn)
arr.find(fn)
arr.includes(item)
[...new Set(arr)]You probably don't need Lodash.
3. Icons Libraries
Font Awesome, Material Icons, Heroicons—icon libraries are sneaky. They seem small, but importing the whole library is devastating:
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { fas } from '@fortawesome/free-solid-svg-icons'; // 1MB+ ❌
library.add(fas);Fix:
// Import only the icons you use
import { faUser, faHome, faCog } from '@fortawesome/free-solid-svg-icons';Or switch to an icon library designed for tree shaking:
import { UserIcon, HomeIcon, CogIcon } from '@heroicons/react/24/outline';4. Chart Libraries
Chart.js, Highcharts, Recharts—visualization libraries are big. If you only use one chart type, you're shipping code for all of them.
import Chart from 'chart.js/auto'; // 200KB+ ❌Fix:
// Import only what you need
import {
Chart,
LineController,
LineElement,
PointElement,
LinearScale,
CategoryScale,
Tooltip,
} from 'chart.js';
Chart.register(
LineController,
LineElement,
PointElement,
LinearScale,
CategoryScale,
Tooltip
);For simpler cases, consider lightweight alternatives like uPlot (20KB) or hand-rolled SVG.
5. Syntax Highlighters
Prism.js and highlight.js ship grammars for hundreds of languages. If you're building a blog that only shows JavaScript, you don't need the COBOL grammar.
// highlight.js
import hljs from 'highlight.js'; // 800KB with all languages ❌
import hljs from 'highlight.js/lib/core'; // 30KB ✅
import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript';
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('typescript', typescript);6. Random "Utility" Packages
I've seen bundles bloated by:
is-odd(checks if a number is odd—just usen % 2 !== 0)left-pad(infamous, just use.padStart())is-number(just usetypeof x === 'number')is-array(just useArray.isArray())
Audit your dependencies. Run:
npx depcheckRemove anything you don't need. Be suspicious of any package that does one trivial thing.
Code Splitting
Not everything needs to load upfront. Split your bundle by route and by feature.
Route-Based Splitting
// React Router
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
function App() {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
);
}Now /analytics doesn't load until someone navigates there.
Feature-Based Splitting
Heavy features should load on demand:
// Load the markdown editor only when needed
const MarkdownEditor = lazy(() => import('./components/MarkdownEditor'));
function PostForm({ enableMarkdown }) {
return (
<form>
{enableMarkdown ? (
<Suspense fallback={<Textarea />}>
<MarkdownEditor />
</Suspense>
) : (
<Textarea />
)}
</form>
);
}Third-Party Splitting
Big libraries can be split too:
// Don't import PDF.js until someone clicks "Export PDF"
const handleExportPDF = async () => {
const { jsPDF } = await import('jspdf');
const doc = new jsPDF();
doc.text('Hello', 10, 10);
doc.save('export.pdf');
};Tree Shaking Isn't Magic
Tree shaking removes unused exports. But it only works if:
- You use ES modules (import/export, not require)
- The library supports it (exports named exports, not just default)
- There are no side effects (or
sideEffects: falsein package.json)
Check if your library supports tree shaking. Many popular ones don't out of the box:
- ❌ Moment.js
- ❌ Lodash (use lodash-es)
- ✅ date-fns
- ✅ Ramda
If tree shaking isn't working, check for:
- CommonJS imports (
require()) - Barrel files that re-export everything
- Side effects in module scope
The Nuclear Options
When you've done everything else:
Replace React
Preact is 3KB vs React's 40KB. API is nearly identical:
// vite.config.js
export default {
resolve: {
alias: {
'react': 'preact/compat',
'react-dom': 'preact/compat',
}
}
};Server-Side Render Heavy Content
Some things don't need to be interactive. Render them on the server:
// Instead of shipping a markdown parser
import ReactMarkdown from 'react-markdown';
<ReactMarkdown>{content}</ReactMarkdown> // 30KB
// Render to HTML at build time
const html = renderMarkdownToHtml(content);
<div dangerouslySetInnerHTML={{ __html: html }} /> // 0KBCompression
Obvious but often misconfigured. Enable gzip/brotli on your server:
| Format | Size |
|---|---|
| Uncompressed | 400KB |
| Gzip | 120KB |
| Brotli | 100KB |
That's free performance. Just turn it on.
The Mindset Shift
The real fix isn't any single technique. It's treating bundle size as a feature, not an afterthought.
- Check bundle size in CI (fail if it grows unexpectedly)
- Set budgets (
performance.maxAssetSizein Webpack) - Review the bundle analyzer before every release
- Question every new dependency
My rule: if a package adds more than 10KB gzipped, it needs to justify its existence. Most can't.
---
The first step is always the bundle analyzer. Run it today. You'll be horrified. And then you'll know what to fix.