Back to Performance
Performance
medium
mid

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.

8 min read·~5 min to think through

Bundle analysis is detective work. Start with a visualization, find the surprises, fix the biggest ones, repeat.

Step 1: get a visual

ToolStack
webpack-bundle-analyzerWebpack
vite-bundle-visualizerVite
@next/bundle-analyzerNext.js
source-map-explorerAny (from source maps)
rollup-plugin-visualizerRollup
esbuild --analyzeesbuild

Run once. Open the treemap. You're looking for:

  1. Unexpectedly large nodesmoment (200KB), lodash (75KB if you imported the namespace), pdfjs, mapbox-gl.
  2. Duplicates — two copies of React, two copies of a util lib from peer-dep mismatches.
  3. Mega-chunks — one chunk holds everything; means split points aren't doing their job.
  4. Polyfills you don't needcore-js for 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) or dayjs (~7KB).
  • lodash full import → lodash-es per-function imports, or native ES (Array.prototype.flat, structuredClone).
  • Big icon set (mdi, fontawesome) → import per icon, or use lucide-react with tree-shaking.
  • axios → native fetch (if you don't need its features).

Per-function imports:

js
// 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:

js
// next.config.js
modularizeImports: {
  lodash: { transform: 'lodash/{{member}}' },
}

Deduplicate:

bash
npm ls react       # any duplicates?
npm dedupe

Peer-dep mismatches are a common cause. overrides (npm) / resolutions (yarn) can force one version.

Code-split heavy widgets:

jsx
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": false in 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:

js
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:

json
"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_modules size 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.

Senior engineer discussion

Seniors set a budget, enforce it in CI, run the analyzer regularly (not just when things break), and have an opinion about dependencies before they land. They prefer fewer, smaller deps to many large ones, lazy-load anything not on the critical path, and pair bundle work with real LCP/INP measurement so they know the byte savings translated to user-visible speed.

Related questions