Why Bundle Size Matters
JavaScript is the most expensive resource on the web — expensive to download, parse, and execute. A 1MB JS bundle on a mid-range phone can take 8-10 seconds to parse before any JavaScript executes.
The goal isn't minimalism for its own sake — it's getting interactive faster.
Step 1: Measure What You Have
You can't optimize what you don't measure.
Bundle Analyzer
# For Next.js
npm install -D @next/bundle-analyzer
# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({})
# Run analysis
ANALYZE=true next build
This opens a treemap showing exactly what's in your bundle and how big each piece is. Usually you'll find one or two packages that account for 40-60% of the total.
Bundlephobia
Before installing a package, check its cost at bundlephobia.com. It shows gzipped size and load time estimates.
Step 2: Kill the Big Offenders
Moment.js → date-fns or Temporal
Moment.js is 280KB minified, 67KB gzipped. date-fns lets you import only what you use:
// Before: 67KB gzipped
import moment from 'moment'
const formatted = moment(date).format('YYYY-MM-DD')
// After: ~2KB for these specific functions
import { format } from 'date-fns'
const formatted = format(date, 'yyyy-MM-dd')
Lodash → native or lodash-es
// Before: imports entire lodash (70KB)
import _ from 'lodash'
_.debounce(fn, 300)
// After option 1: named import (still pulls in lodash internals)
import { debounce } from 'lodash'
// After option 2: direct function import (~2KB)
import debounce from 'lodash/debounce'
// After option 3: native (zero KB)
const debounce = (fn, ms) => {
let timeout
return (...args) => {
clearTimeout(timeout)
timeout = setTimeout(() => fn(...args), ms)
}
}
Icon Libraries
// Before: pulls in every icon (2MB+)
import { FiArrowRight } from 'react-icons/fi'
// After: direct import (~1KB)
import FiArrowRight from 'react-icons/fi/FiArrowRight'
Step 3: Code Split Aggressively
Split your bundle so users only download what they need for the current page.
Dynamic Imports
// Before: everything loads upfront
import { HeavyEditor } from './Editor'
import { ReportGenerator } from './Reports'
// After: load when needed
const HeavyEditor = lazy(() => import('./Editor'))
const ReportGenerator = lazy(() => import('./Reports'))
// In component:
<Suspense fallback={<Skeleton />}>
{showEditor && <HeavyEditor />}
</Suspense>
Route-based Splitting (Next.js)
Next.js does this automatically — each page is a separate chunk. Ensure you're not importing heavy things at the root layout level.
Step 4: Tree Shake Properly
Tree shaking removes unused exports, but only works with ES modules:
// Good: tree-shakeable (ES module named export)
export function add(a, b) { return a + b }
export function subtract(a, b) { return a - b }
// Bad: not tree-shakeable (CommonJS)
module.exports = { add, subtract }
Check your dependencies: some packages ship only CommonJS (require) which can't be tree-shaken. Look for packages with "module" field in package.json or sideEffects: false.
Step 5: Defer Non-Critical JavaScript
<!-- Blocks HTML parsing — don't do this -->
<script src="app.js"></script>
<!-- Parses HTML, executes after DOM ready -->
<script src="app.js" defer></script>
<!-- Downloads in parallel, executes immediately when done -->
<script src="analytics.js" async></script>
Use defer for your app bundle. Use async for truly independent scripts like analytics.
Step 6: Measure the Real Impact
Bundle size is a proxy metric. Measure what users actually experience:
// Measure Time to Interactive in the console
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
console.log('FCP:', entry.startTime)
}
}
}).observe({ type: 'paint', buffered: true })
Use Lighthouse to get TTI (Time to Interactive) — this is the real metric that bundle size affects.
Quick Wins Checklist
| Action | Expected Saving |
|---|---|
| Replace Moment.js with date-fns | ~65KB |
| Replace full lodash with per-function imports | ~50KB |
| Fix icon library imports | ~100KB+ |
| Add bundle analyzer and audit | Find your specific culprits |
| Lazy load below-fold components | 20-40% reduction in initial bundle |
| Enable gzip/brotli on server | 60-80% transfer size reduction |
Key Takeaways
- Use
@next/bundle-analyzerto visualize what's in your bundle - The biggest wins are almost always one or two large packages (Moment, Lodash, icon sets)
- Code split with dynamic imports — load code when it's needed, not upfront
- Tree shaking requires ES modules — check your dependencies
- Gzip/Brotli compression on the server is free and cuts transfer size by 60-80%