How do you analyze and optimize a frontend bundle?
Run a bundle analyzer (webpack-bundle-analyzer, vite-bundle-visualizer, next-bundle-analyzer) to visualize chunk composition. Look for: oversized dependencies (moment, lodash full import), duplicates (multiple React versions), big mega-chunks, unused exports not tree-shaken. Fix by replacing heavy deps, importing per-function (lodash-es), code-splitting routes/widgets, externalizing big polyfills. Set a budget in CI; fail the build when chunks regress.
Bundle analysis is detective work. Start with a visualization, find the surprises, fix the biggest ones, repeat.
Step 1: get a visual
| Tool | Stack |
|---|---|
webpack-bundle-analyzer | Webpack |
vite-bundle-visualizer | Vite |
@next/bundle-analyzer | Next.js |
source-map-explorer | Any (from source maps) |
rollup-plugin-visualizer | Rollup |
esbuild --analyze | esbuild |
Run once. Open the treemap. You're looking for:
- Unexpectedly large nodes —
moment(200KB),lodash(75KB if you imported the namespace),pdfjs,mapbox-gl. - Duplicates — two copies of React, two copies of a util lib from peer-dep mismatches.
- Mega-chunks — one chunk holds everything; means split points aren't doing their job.
- Polyfills you don't need —
core-jsfor IE11 in a modern-only app.
Step 2: common fixes (highest ROI first)
Replace heavy deps with smaller equivalents:
moment(~290KB) →date-fns(tree-shakeable, ~10–30KB used) ordayjs(~7KB).lodashfull import →lodash-esper-function imports, or native ES (Array.prototype.flat, structuredClone).- Big icon set (mdi, fontawesome) → import per icon, or use
lucide-reactwith tree-shaking. axios→ nativefetch(if you don't need its features).
Per-function imports:
// Bad — pulls in all of lodash
import _ from 'lodash';
_.debounce(fn, 100);
// Good — only debounce ships
import debounce from 'lodash-es/debounce';For libraries that don't tree-shake well, configure modular imports in Next:
// next.config.js
modularizeImports: {
lodash: { transform: 'lodash/{{member}}' },
}Deduplicate:
npm ls react # any duplicates?
npm dedupePeer-dep mismatches are a common cause. overrides (npm) / resolutions (yarn) can force one version.
Code-split heavy widgets:
const Chart = dynamic(() => import('./Chart'), { ssr: false });Externalize big polyfills:
If you only need Intl in 1% of locales, dynamic-import the polyfill instead of bundling.
Tree-shaking checks:
- Library has
"sideEffects": falsein its package.json? Tree-shaking works. - Importing CSS as a side effect (
import './foo.css')? Mark it in sideEffects. - Using
require? Webpack/esbuild can't always tree-shake CommonJS.
Step 3: budget + CI guard
Set a budget. Fail the build if a chunk grows beyond it.
Webpack:
performance: {
maxAssetSize: 200_000,
maxEntrypointSize: 300_000,
hints: 'error',
}Next: @next/bundle-analyzer in CI + a script that parses the manifest and fails if specific routes exceed a budget.
Use size-limit as a stack-agnostic CI guard:
"size-limit": [
{ "path": ".next/static/chunks/main-*.js", "limit": "150 KB" }
]A budget is the single most important tool — without one, regressions sneak in PR by PR until someone runs an analyzer and finds a 2MB main chunk.
Step 4: ongoing hygiene
- Quarterly dependency audit: any unused deps (
depcheck)? - Bundlephobia.com on new deps before adding.
- Pin React Server Components (Next App Router) so server-only code doesn't ship to client.
- Use
'use client'boundaries deliberately — every client component is a JS handoff. - Audit
node_modulessize occasionally — sometimes a dev dep accidentally became a runtime dep.
What to ignore
- Tiny shavings (saving 2KB in a 300KB bundle isn't worth a refactor).
- Theoretical wins that don't move LCP/INP — real metrics are the goal, not the analyzer.
- Aggressive splitting on the critical path — too many tiny chunks hurt.
Mental model
A bundle is a budget. Spend it on code that runs above the fold; defer the rest. The first 200KB of JS roughly maps to time-to-interactive on mid-range mobile — anything beyond that needs justification.
Follow-up questions
- •How do you decide between replacing a heavy dep vs lazy-loading it?
- •What's a sensible JS budget for a SaaS dashboard?
- •How do React Server Components change bundle analysis?
- •How do you detect duplicate dependencies?
Common mistakes
- •Importing whole namespaces (`import _ from 'lodash'`) — pulls in 75KB unused.
- •Forgetting `sideEffects: false` in your own packages — tree-shaking can't prune.
- •Letting two React copies ship — doubles the bundle and breaks hooks.
- •Polyfilling for browsers you don't support.
- •Mega vendor chunk that invalidates on every dep update.
- •Tracking score (Lighthouse) instead of bytes — the analyzer is the source of truth for size.
Performance considerations
- •Bundle size maps to: time to download (network-bound), time to parse (CPU-bound — surprisingly expensive on low-end Android), and time to execute (more code = more main-thread work). Cutting 200KB typically improves TTI by 200–500ms on mid-range mobile. Don't underestimate parse cost — it's often the dominant cost on slow devices.
Edge cases
- •CommonJS deps can defeat tree-shaking; check if an ESM version exists.
- •Dynamic import paths (`import(`./locales/${lang}.json`)`) can pull in everything matching the pattern unless the bundler narrows it.
- •Server-only code accidentally imported by a client component leaks server libs into the client bundle.
- •Stylesheets imported by JS become part of the JS chunk graph — heavy CSS-in-JS or huge sprite imports inflate JS chunks.
- •Workers and SW scripts have their own bundles — analyze separately.
Real-world examples
- •Tinder dropped moment.js for date-fns and saved ~200KB.
- •Slack reduced their initial bundle by tens of percent via aggressive RSC adoption.
- •Sentry's web SDK is famous for shipping small via aggressive tree-shaking + plugin imports.