Back to Performance
Performance
medium
very high
mid

How do you reduce JavaScript bundle size in production?

Measure first, then: tree-shake, route-split, dynamic import heavy widgets, swap heavy deps, ship modern syntax, and budget aggressively.

8 min read·~15 min to think through

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.json has "sideEffects": false (or a precise list).
  • You use named imports: import { debounce } from 'lodash-es' not import _ from 'lodash' (the latter pulls all of lodash).
  • Avoid barrel files (index.ts that 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) or dayjs (2KB).
  • lodash (24KB) → native or lodash-es/fn per function.
  • recharts / Chart.js (~100KB) → uplot / @nivo subset / 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 browserslist to 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

tsx
import dynamic from "next/dynamic";

const Chart = dynamic(() => import("@/features/analytics/Chart"), {
  ssr: false,
  loading: () => <ChartSkeleton />,
});
Dynamic import with skeleton
json
{
  "size-limit": [
    { "path": ".next/static/chunks/main-*.js", "limit": "80 KB" },
    { "path": ".next/static/chunks/pages/index-*.js", "limit": "30 KB" }
  ]
}
size-limit budget in package.json

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.

Related questions