How do you reduce bundle size in production?
Measure first, then: tree-shake, route-split, dynamic import heavy widgets, swap heavy deps, ship modern syntax, and budget aggressively.
Bundle size is the single biggest predictor of mobile Time to Interactive on cold loads — every 100KB of JS costs ~100–300ms of parse-and-execute on a mid-range phone, before any network cost. So the goal isn't a smaller number on a treemap; it's faster INP and TTI on real devices. Work the problem in order: measure → kill the worst offender → set a budget → repeat.
1. Measure first. Never optimize blind. Use:
next build— prints a per-route first-load JS table; the canonical scoreboard.@next/bundle-analyzer/webpack-bundle-analyzer— treemap of what's in each chunk.source-map-explorer— source-level attribution for any bundle.statoscope— diff two builds; great for "what just regressed."- Real-user metrics (LCP, INP from CrUX) — the actual user-felt outcome.
Pick the heaviest module on the treemap; you can usually delete or replace it in an hour.
2. Route-level code splitting is free in Next.js / Remix / TanStack Router. Verify every route's first-load JS budget — aim for <150KB gzip for landing routes, <250KB for app routes. If a route is heavy, the cause is usually one heavy dependency leaking into the root layout.
3. Dynamic import below-the-fold or interaction-gated code. Charts, code editors, video players, rich-text editors, comment forms, modals — all candidates for next/dynamic or React.lazy + <Suspense>. Pattern: const Chart = dynamic(() => import('./Chart'), { ssr: false, loading: () => <Skeleton/> }). The library only ships when the user opens the panel.
4. Tree-shaking — make sure it actually works. Bundlers can only tree-shake ESM with explicit side-effect markers. Verify:
- Your library's
package.jsonhas"sideEffects": false(or a precise list). - You use named imports:
import { debounce } from 'lodash-es'notimport _ from 'lodash'(the latter pulls all of lodash). - Avoid barrel files (
index.tsthat re-exports everything) in heavily-imported packages — they defeat tree-shaking in some bundler configs. Import from deep paths or use/* @__PURE__ */annotations. - Use ESM dependencies; CJS sometimes blocks shaking.
5. Replace the worst offenders. Common wins:
moment(60KB) →date-fns(per-fn) ordayjs(2KB).lodash(24KB) → native orlodash-es/fnper function.recharts/ Chart.js (~100KB) →uplot/@nivosubset / custom SVG.- Icon packs — import individual icons (
lucide-react's tree-shaking, or HeroIcons individual paths), not the whole index. react-icons(default barrel) → swap to specific packs.- Polyfills (core-js) — audit
browserslistto drop anything pre-2020.
6. Ship modern syntax. Configure browserslist to exclude IE11 and very old Safari/Android. SWC / esbuild then emits ES2022 directly; no Babel polyfills, no Symbol shims, no helper duplication. If you must serve legacy browsers, use differential serving (modulepreload modern + nomodule legacy).
7. Polyfill audit. @babel/preset-env with useBuiltIns: "entry" blindly imports polyfills based on targets. Check the actual emitted code; you may be shipping URL and Promise.finally polyfills for Chrome users.
8. Shared / vendor chunks. Bundlers split common deps into shared chunks (framework.js, vendors.js). Ensure no rarely-used giant library has leaked into every route's shared chunk. next build prints per-chunk sizes; bundle-analyzer shows what's inside.
9. Server Components & "use server". In the App Router, components that don't need interactivity stay on the server and ship zero JS. The biggest single bundle win in a Next 13+ migration is correctly tagging client boundaries.
10. CSS. Tailwind purges unused classes automatically; verify in production. Inline critical CSS (Next.js does this for App Router). Avoid CSS-in-JS that ships a runtime; prefer compile-time CSS (Vanilla Extract, Linaria, Tailwind).
11. Image bundles. SVG icons inlined in JS bloat the bundle. Use <Image> with the asset pipeline; lazy-load via loading="lazy".
12. CI budgets. Add a size-limit or Lighthouse CI step that fails the PR if any route's first-load JS grows beyond budget (or grows >5KB unexpectedly). Without a guard, the bundle grows monotonically — every new feature adds bytes, almost none remove them.
Workflow recap. Open the analyzer → pick the largest leaf → either dynamic-import it, replace it, or delete it → re-measure → repeat. After a few passes you've usually halved the bundle. Then put a CI guard in place so it stays there.
Code
Follow-up questions
- •How does Next.js handle tree-shaking with App Router?
- •What's the trade-off in dynamic-importing every component?
- •How do you detect a regression at PR time?
Common mistakes
- •Dynamic-importing tiny components — adds network/runtime overhead for no win.
- •Default-importing entire libraries.
- •Ignoring polyfill bloat.
Performance considerations
- •Bundle size affects TTI more than LCP — but both via main thread block.
- •Network cost is non-linear: an extra 50KB on slow 3G is brutal.
Edge cases
- •Dynamic imports in event handlers cause click → fetch latency; preload on hover for critical interactions.
Real-world examples
- •Shopify cut their checkout JS by 40% by moving moment → date-fns and per-route splits.