Bundle Optimization
Purpose
Reduce initial JavaScript payload so First Contentful Paint and Time to Interactive stay fast. Each kilobyte less = lower mobile failure rate, lower battery use, faster page.
Universal — bundle-bloat patterns (barrel imports, dead code, duplicate-purpose deps, unnecessary client work) are bundler-agnostic; only the analyzer tool and config syntax differ.
Procedure
Analyze the bundle — per route, not just total
- Run the bundler's size analyzer; sort by size, identify top 5 contributors
- The metric that matters is initial JS per route, plus the shared/root layer (a fat root layout or a barrel in a widely-imported shared component inflates every route). Measure compressed-as-served (most CDNs serve Brotli, smaller than gzip).
Audit barrel-file imports (#1 silent bundle bloat)
- Libraries that ship via
index.ts re-exports (icon sets, date-fns, lodash, @radix-ui/*) pull the whole library when tree-shaking can't resolve the re-export
- Fix at the import level: a barrel-aware bundler config OR direct subpath imports (
date-fns/addDays)
- Check what the bundler already optimizes by default before hand-listing packages
- See Implementation for the per-bundler setting (Next
optimizePackageImports, …)
2b. Move heavy workloads to the server + keep the client boundary narrow
- In any meta-framework with a server/client (or static/island) split, the server↔client boundary is the #1 bundle lever — everything on the client side ships. A boundary placed too high (a whole page/layout marked interactive) drags its entire subtree into the bundle; push it to the smallest genuinely-interactive leaf (coordinate with
architecture-improvement).
- Render heavy, non-interactive work server-side (syntax highlighters, markdown, charts, image processing) so its JS never ships
- Server-only heavy deps (native bindings, headless browsers) stay out of the client bundle entirely
- See Implementation for the per-framework directive (React
'use client', Astro client:* islands, Nuxt <ClientOnly>, …)
Identify defer candidates
- Components not on the initial render → lazy-load (heavy chart libs, code editors, video players; modals load on open, not eagerly)
- See Implementation for the framework primitive (
next/dynamic / React.lazy, …)
Tree-shaking checks
- CJS-only packages defeat tree-shaking → find ESM alternatives
- Replace
import * as X from 'lib' with named imports
Remove unused dependencies
npx knip — finds unused exports AND unused deps
- Or
npx depcheck for deps only
- Verify before removing (some are runtime-loaded)
Detect duplicate dependencies
- Same purpose, different libs —
moment + date-fns, axios + ky + native fetch → standardize on one
- Same package, multiple versions in the tree (
npm ls <pkg> / npm dedupe) — transitive version duplication (two React copies, several lodash) is invisible to "one purpose, one lib" yet ships twice
Audit polyfills
- Modern browsers don't need IE polyfills
target in tsconfig.json and browserslist should reflect actual support matrix
Verify (validation loop)
- Re-run analyzer; if initial JS > target, identify the new top contributor and repeat steps 2-7 until ≤ target
- Re-run Lighthouse; if Performance < 90, return to
rendering-performance skill
- If a defer broke a critical-path render, restore eager loading for that piece and find a different cut
Severity tiers
| Tier |
Examples |
Action SLA |
| Critical |
Initial JS > 500KB gzip; single dependency > 200KB gzip; duplicate-purpose deps (moment + date-fns + dayjs all installed) |
Block release; fix immediately |
| Major |
Initial JS 200-500KB; ≥ 3 unused dependencies; barrel-file imports for 5+ packages |
Fix this sprint |
| Minor |
Initial JS < 200KB but room to improve; 1-2 unused dependencies; minor tree-shaking gaps |
Schedule within 2 sprints |
Default target if no project-specific target is set: ≤ 200KB initial JS per route (compressed-as-served).
Completion Criteria
Stop & Ask (AI must pause for user approval)
- Before removing any dependency flagged as "unused" by
knip / depcheck — runtime-loaded deps (e.g., dynamic import() strings) can be missed by static analysis
- Before replacing a CJS dep with an ESM alternative — runtime behavior may differ subtly
- Before deferring a component with
next/dynamic that lives above the fold — risk of CLS or LCP regression
Output
- Build config: updated
next.config.ts with optimizePackageImports (or serverExternalPackages for server-only deps)
- Refactored imports: subpath imports for non-treeshakable barrels; commit format
perf(bundle): switch to subpath imports for <package>
- Bundle report:
docs/bundle-size-YYYY-MM-DD.md with:
- Initial JS size (before / after, gzip)
- Top 5 contributors (before / after)
- Tree-shaking gaps identified
- Removed deps + reason
- PR description: paste the bundle report summary table
Implementation
React + Next.js (default)
- Analyzer:
npx next experimental-analyze (Turbopack, Next.js 16.1+) or @next/bundle-analyzer (Webpack)
- Barrel optimization: top-level
optimizePackageImports: ['lucide-react', 'date-fns', ...] in next.config.ts
- Defer:
next/dynamic({ ssr: false }) or React.lazy
- Server-side heavy work: convert to Server Components — syntax highlighters, markdown renderers, chart libs render to HTML on the server (never shipped to client)
'use client' boundary: place at the smallest interactive leaf, not layout/page root — everything below it ships to the client
- Server-only deps:
serverExternalPackages: ['sharp', 'puppeteer'] in next.config.ts — excludes from server bundle (loaded at runtime)
- Tree-shaking audit + unused deps:
npx knip or npx depcheck
Other stacks
- Vite (any framework):
npx vite-bundle-visualizer; build.rollupOptions.output.manualChunks; dynamic import() for code splitting
- Vue / Nuxt: Nuxt has
experimental.payloadExtraction; defineAsyncComponent; nuxt-icon (lazy-loaded SVGs)
- SvelteKit: built on Vite — use Vite analyzer; lazy components via dynamic
import(); +page.svelte is naturally lazy per route
- Angular:
ng build --stats-json + webpack-bundle-analyzer; loadChildren for lazy routes; standalone components for tree-shaking
- Universal: avoid barrel re-exports (
import { x } from 'lib' when the lib's index.ts re-exports 100 things); use direct subpath imports; knip works across stacks
Related skills
rendering-performance — bundle size affects LCP/FCP
architecture-improvement — barrel files and feature boundaries influence bundling
new-tech-evaluation — evaluate library size before adopting
Reference
- Key insight encoded: Barrel files (
index.ts re-exports) defeat tree-shaking and silently bloat bundles — use optimizePackageImports for libraries with hundreds of exports (lucide-react, lodash, date-fns) before reaching for manual next/dynamic. Most "bundle bloat" issues resolve at the import level without changing component code.
1---2name: bundle-optimization3description: Reduce initial JS bundle size via Bundle Analyzer, optimizePackageImports, code splitting, and unused-dep removal. Use when bundle exceeds target, after adding a large dependency, when Lighthouse Performance drops, or before shipping. Not for diagnosing runtime Core Web Vitals (use rendering-performance) or evaluating a library's size before adopting it (use new-tech-evaluation).4license: MIT5---67# Bundle Optimization89## Purpose10Reduce initial JavaScript payload so First Contentful Paint and Time to Interactive stay fast. Each kilobyte less = lower mobile failure rate, lower battery use, faster page.1112**Universal** — bundle-bloat patterns (barrel imports, dead code, duplicate-purpose deps, unnecessary client work) are bundler-agnostic; only the analyzer tool and config syntax differ.1314## Procedure15161. **Analyze the bundle — per route, not just total**17 - Run the bundler's size analyzer; sort by size, identify top 5 contributors18 - The metric that matters is **initial JS per route**, plus the **shared/root layer** (a fat root layout or a barrel in a widely-imported shared component inflates *every* route). Measure compressed-as-served (most CDNs serve Brotli, smaller than gzip).19202. **Audit barrel-file imports (#1 silent bundle bloat)**21 - Libraries that ship via `index.ts` re-exports (icon sets, `date-fns`, `lodash`, `@radix-ui/*`) pull the *whole* library when tree-shaking can't resolve the re-export22 - Fix at the **import level**: a barrel-aware bundler config OR direct subpath imports (`date-fns/addDays`)23 - Check what the bundler already optimizes by default before hand-listing packages24 - See Implementation for the per-bundler setting (Next `optimizePackageImports`, …)25262b. **Move heavy workloads to the server + keep the client boundary narrow**27 - In any meta-framework with a server/client (or static/island) split, **the server↔client boundary is the #1 bundle lever** — everything on the client side ships. A boundary placed too high (a whole page/layout marked interactive) drags its entire subtree into the bundle; push it to the smallest genuinely-interactive leaf (coordinate with `architecture-improvement`).28 - Render heavy, non-interactive work server-side (syntax highlighters, markdown, charts, image processing) so its JS never ships29 - Server-only heavy deps (native bindings, headless browsers) stay out of the client bundle entirely30 - See Implementation for the per-framework directive (React `'use client'`, Astro `client:*` islands, Nuxt `<ClientOnly>`, …)31323. **Identify defer candidates**33 - Components not on the initial render → **lazy-load** (heavy chart libs, code editors, video players; modals load on open, not eagerly)34 - See Implementation for the framework primitive (`next/dynamic` / `React.lazy`, …)35364. **Tree-shaking checks**37 - CJS-only packages defeat tree-shaking → find ESM alternatives38 - Replace `import * as X from 'lib'` with named imports39405. **Remove unused dependencies**41 - `npx knip` — finds unused exports AND unused deps42 - Or `npx depcheck` for deps only43 - Verify before removing (some are runtime-loaded)44456. **Detect duplicate dependencies**46 - **Same purpose, different libs** — `moment` + `date-fns`, `axios` + `ky` + native `fetch` → standardize on one47 - **Same package, multiple *versions*** in the tree (`npm ls <pkg>` / `npm dedupe`) — transitive version duplication (two React copies, several lodash) is invisible to "one purpose, one lib" yet ships twice48497. **Audit polyfills**50 - Modern browsers don't need IE polyfills51 - `target` in `tsconfig.json` and `browserslist` should reflect actual support matrix52538. **Verify (validation loop)**54 - Re-run analyzer; if initial JS > target, identify the new top contributor and repeat steps 2-7 until ≤ target55 - Re-run Lighthouse; if Performance < 90, return to `rendering-performance` skill56 - If a defer broke a critical-path render, restore eager loading for that piece and find a different cut5758## Severity tiers5960| Tier | Examples | Action SLA |61|---|---|---|62| **Critical** | Initial JS > 500KB gzip; single dependency > 200KB gzip; duplicate-purpose deps (moment + date-fns + dayjs all installed) | Block release; fix immediately |63| **Major** | Initial JS 200-500KB; ≥ 3 unused dependencies; barrel-file imports for 5+ packages | Fix this sprint |64| **Minor** | Initial JS < 200KB but room to improve; 1-2 unused dependencies; minor tree-shaking gaps | Schedule within 2 sprints |6566**Default target if no project-specific target is set**: ≤ 200KB initial JS **per route** (compressed-as-served).6768## Completion Criteria69- [ ] Initial JS ≤ project target per route (default 200KB if unset)70- [ ] Bundle-size budget enforced as a CI gate (`size-limit` / `bundlewatch`) so regressions fail the PR (wire via `cicd-pipeline`)71- [ ] Lighthouse Performance ≥ 9072- [ ] No unused dependencies (`knip` clean); no duplicate deps (same-purpose *or* multiple versions)73- [ ] Barrel-file imports resolved (either `optimizePackageImports` or subpath)74- [ ] All Critical findings fixed; all Major findings scheduled7576## Stop & Ask (AI must pause for user approval)7778- **Before removing any dependency** flagged as "unused" by `knip` / `depcheck` — runtime-loaded deps (e.g., dynamic `import()` strings) can be missed by static analysis79- **Before replacing a CJS dep with an ESM alternative** — runtime behavior may differ subtly80- **Before deferring a component with `next/dynamic`** that lives above the fold — risk of CLS or LCP regression8182## Output83- **Build config**: updated `next.config.ts` with `optimizePackageImports` (or `serverExternalPackages` for server-only deps)84- **Refactored imports**: subpath imports for non-treeshakable barrels; commit format `perf(bundle): switch to subpath imports for <package>`85- **Bundle report**: `docs/bundle-size-YYYY-MM-DD.md` with:86 - Initial JS size (before / after, gzip)87 - Top 5 contributors (before / after)88 - Tree-shaking gaps identified89 - Removed deps + reason90- **PR description**: paste the bundle report summary table9192## Implementation9394### React + Next.js (default)95- Analyzer: `npx next experimental-analyze` (Turbopack, Next.js 16.1+) or `@next/bundle-analyzer` (Webpack)96- Barrel optimization: top-level `optimizePackageImports: ['lucide-react', 'date-fns', ...]` in `next.config.ts`97- Defer: `next/dynamic({ ssr: false })` or `React.lazy`98- Server-side heavy work: convert to Server Components — syntax highlighters, markdown renderers, chart libs render to HTML on the server (never shipped to client)99- `'use client'` boundary: place at the smallest interactive leaf, not layout/page root — everything below it ships to the client100- Server-only deps: `serverExternalPackages: ['sharp', 'puppeteer']` in `next.config.ts` — excludes from server bundle (loaded at runtime)101- Tree-shaking audit + unused deps: `npx knip` or `npx depcheck`102103### Other stacks104- **Vite (any framework)**: `npx vite-bundle-visualizer`; `build.rollupOptions.output.manualChunks`; dynamic `import()` for code splitting105- **Vue / Nuxt**: Nuxt has `experimental.payloadExtraction`; `defineAsyncComponent`; `nuxt-icon` (lazy-loaded SVGs)106- **SvelteKit**: built on Vite — use Vite analyzer; lazy components via dynamic `import()`; `+page.svelte` is naturally lazy per route107- **Angular**: `ng build --stats-json` + `webpack-bundle-analyzer`; `loadChildren` for lazy routes; standalone components for tree-shaking108- **Universal**: avoid barrel re-exports (`import { x } from 'lib'` when the lib's `index.ts` re-exports 100 things); use direct subpath imports; `knip` works across stacks109110## Related skills111- `rendering-performance` — bundle size affects LCP/FCP112- `architecture-improvement` — barrel files and feature boundaries influence bundling113- `new-tech-evaluation` — evaluate library size before adopting114115## Reference116- **Key insight encoded**: Barrel files (`index.ts` re-exports) defeat tree-shaking and silently bloat bundles — use `optimizePackageImports` for libraries with hundreds of exports (lucide-react, lodash, date-fns) before reaching for manual `next/dynamic`. Most "bundle bloat" issues resolve at the import level without changing component code.