# Bundle Optimization

> 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).

- Skill: `jaykim88/bundle-optimization` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jaykim88/bundle-optimization`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jaykim88/bundle-optimization/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: MIT
- Author: JayKim88 (https://skillmd.com/u/jaykim88)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/jaykim88/bundle-optimization

---


# 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

1. **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).

2. **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>`, …)

3. **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`, …)

4. **Tree-shaking checks**
   - CJS-only packages defeat tree-shaking → find ESM alternatives
   - Replace `import * as X from 'lib'` with named imports

5. **Remove unused dependencies**
   - `npx knip` — finds unused exports AND unused deps
   - Or `npx depcheck` for deps only
   - Verify before removing (some are runtime-loaded)

6. **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

7. **Audit polyfills**
   - Modern browsers don't need IE polyfills
   - `target` in `tsconfig.json` and `browserslist` should reflect actual support matrix

8. **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
- [ ] Initial JS ≤ project target per route (default 200KB if unset)
- [ ] Bundle-size budget enforced as a CI gate (`size-limit` / `bundlewatch`) so regressions fail the PR (wire via `cicd-pipeline`)
- [ ] Lighthouse Performance ≥ 90
- [ ] No unused dependencies (`knip` clean); no duplicate deps (same-purpose *or* multiple versions)
- [ ] Barrel-file imports resolved (either `optimizePackageImports` or subpath)
- [ ] All Critical findings fixed; all Major findings scheduled

## 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.

