Frontend bundle size and code splitting

Have agents use bundle analyzers, source maps, and route-level code splitting to control first paint and caching; reading treemaps and budgets is the first step to root-cause size regressions.

The SKILL should state budget thresholds for gzip, brotli, and uncompressed raw sizes, plus a review process for third-party alternatives; avoid pulling admin-only heavy deps into shared chunks.

Dynamic import and prefetch plans must match navigation paths and user permissions; load i18n, themes, and other large assets on demand with long-cache fingerprints.

When CI enforces size gates, the agent should explain whether growth came from new dependencies or app code, and propose concrete tree-shaking or splitting fixes.

  • Split vendor from app; stable hashes improve browser caching.
  • Flag paths where synchronous scripts block rendering.
  • Serve images and fonts via CDN in appropriate formats (AVIF, woff2).

Bundle analysis flow (build →treemap →budget)

  [ Prod build: same config as prod (minify, define, target) ]
        │
        ▼
  ┌─────────────│    stats / visualize: webpack-bundle-analyzer, Rollup Visualizer, source-map-explorer
  │output + map│──── treemap: rectangle area ≠module contribution (see tool docs for metric)
  └─────────────│
        │
        ▼
  ┌─────────────│    vs SKILL: main / route / vendor caps; note primary metric gzip or brotli
  │budget table│──── overages: dup deps, full polyfill bundles, sideEffects mistakes, bad async splits
  └─────────────│
        │
        ▼
  ┌─────────────│    size-limit / bundlewatch / custom thresholds; PR attaches treemap screenshot or report
  │CI gate     │──── regression notes: dep versions, import paths, shared-chunk merge policy
  └─────────────┘

Lock the reporting metric before comparing numbers: within one PR, analyzer version, compression algorithm, and whether maps include comments must match or deltas are meaningless.

Configure webpack-bundle-analyzer to generate a static HTML treemap report in CI:

// webpack.config.js — integrate bundle analyzer
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  mode: 'production',
  plugins: [
    // Only activate when ANALYZE=true so normal builds don't open a browser
    process.env.ANALYZE === 'true' && new BundleAnalyzerPlugin({
      analyzerMode: 'static',          // generate static HTML report, suitable for CI
      reportFilename: 'bundle-report.html',
      openAnalyzer: false,             // don't auto-open browser in CI
      generateStatsFile: true,         // also generate stats.json
      statsFilename: 'bundle-stats.json',
    }),
  ].filter(Boolean),
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          priority: 10,
        },
      },
    },
  },
};

// Run: ANALYZE=true npm run build
// In CI: save report as artifact for PR review

Size budgets and metrics (gzip / brotli / raw)

Split budgets by route or entry: main document JS, shared vendor, lazy routes, Worker/WASM on their own rows; the SKILL should say which column the team trusts (many teams use gzip or brotli transfer size; raw helps debug uncompressed bloat).

Sample budget rows with placeholder numbers—replace with your team’s real thresholds in the SKILL
Entry / chunk gzip cap (example) Notes
Main first-load bundle (HTML critical path) 120 KB If framework runtime + global styles share one file, split or isolate vendor
Shared vendor (stable hash) 200 KB Separate from app chunks so small copy changes don’t bust the whole cache
Single lazy route chunk 80 KB Heavy admin pages can have a higher cap with role noted
Compare to budget (gzip, KB)

Match the budget input to a table row, then enter measured size to see over/under budget.

Enter measured gzip size (KB) to see the result.

Reading treemaps (area, duplicates, async boundaries)

Large treemap tiles usually mean expensive deps or entries not tree-shaken; drill to npm paths and check for multiple versions, accidental dist full imports, or sideEffects keeping whole packages.

  • Same library in multiple tiles or paths: check lockfile, aliases, and duplicate CJS vs ESM graphs.
  • Thin app but fat vendor: inspect dynamic import boundaries and whether sync re-exports pull heavy deps into parent chunks.
  • Cross-check source maps: real app imports vs helpers/polyfills injected by plugins.

Hints:

Rectangle area (or tool weight) reflects share of the chosen metric; scan largest tiles first, then expand.

CI regressions and splitting actions

When a gate fails, the PR should describe: baseline comparison on the same config, over-budget chunk names, treemap or report links, and proposed fixes (lighter APIs, lazy loading, splitting presets, keeping dev deps out of prod graphs).

  • Split vendor from app; stable hashes improve browser caching.
  • Flag paths where synchronous scripts block rendering.
  • Serve images and fonts via CDN in appropriate formats (AVIF, woff2).

bundlesize configuration in package.json and GitHub Actions integration:

// package.json — bundlesize configuration
{
  "bundlesize": [
    { "path": "./dist/main.*.js",    "maxSize": "120 kB" },
    { "path": "./dist/vendors.*.js", "maxSize": "200 kB" },
    { "path": "./dist/*.chunk.js",   "maxSize": "80 kB"  }
  ]
}

// .github/workflows/bundle-check.yml
// jobs:
//   bundle-size:
//     runs-on: ubuntu-24.04
//     steps:
//       - uses: actions/checkout@v4
//         with:
//           fetch-depth: 0   # need main branch for baseline comparison
//       - run: npm ci && npm run build
//       - name: Check bundle size
//         run: npx bundlesize
//         env:
//           BUNDLESIZE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
//           # Automatically comments on PR flagging over-budget chunks

// Route-level code splitting with dynamic import
// router.js
const routes = [
  {
    path: '/dashboard',
    // Correct: each route gets its own chunk, supports prefetch
    component: () => import(
      /* webpackChunkName: "dashboard" */
      /* webpackPrefetch: true */
      './views/Dashboard.vue'
    ),
  },
  {
    path: '/admin',
    // Admin page gets its own chunk (can have separate, relaxed budget)
    component: () => import(
      /* webpackChunkName: "admin" */
      './views/Admin.vue'
    ),
  },
];

Tree shaking verification: check which code was removed:

# Verify tree shaking is working: check if unused exports appear in build output

# Method 1: bundle-stats.json + source-map-explorer
npx source-map-explorer dist/main.*.js --json > explorer.json
# Search JSON for function names you think should have been shaken out

# Method 2: webpack --display-used-exports (webpack 4/5)
# Add optimization.usedExports: true to webpack.config.js
# Build logs will annotate [used exports: x, y]

# Method 3: verify sideEffects declaration in package.json
# Without "sideEffects": false, webpack won't tree-shake that package
cat node_modules/lodash-es/package.json | grep sideEffects
# Should see: "sideEffects": false

# Common anti-patterns that break tree shaking:
# Bad:  import _ from 'lodash'              -- pulls in full CJS bundle
# Good: import { debounce } from 'lodash-es' -- ESM + sideEffects:false

SKILL snippet

---
name: bundle-size
description: Analyze frontend bundles, splitting, and size budget gates
tags: [frontend, webpack, performance, bundle]
---
# Analysis Tools
- webpack-bundle-analyzer: ANALYZE=true generates a static HTML treemap report
- source-map-explorer: derives per-module sizes from source maps, validates tree shaking
- bundlesize: configure budgets in package.json; PR gets automatic comments

# Budget Metrics
- Primary metric: gzip or brotli transfer size (matches CDN actual transfer)
- Set separate budgets per row: first-paint main bundle, vendor chunk, single lazy route chunk
- CI gate with bundlesize or size-limit; on failure explain whether growth is new dep or app code

# Tree Shaking Verification
- Confirm target package has "sideEffects": false and exports an ESM entry
- Use named imports (import { debounce } from 'lodash-es') not default full-package import
- optimization.usedExports: true annotates used/unused exports in build logs

# Code Splitting
- Route-level dynamic import(): each route gets its own chunk with webpackChunkName
- webpackPrefetch: true pre-fetches the next page chunk after main bundle loads
- Admin heavy pages get a separate chunk with a more relaxed independent budget
- Separate vendor from app: node_modules in its own cacheGroup for stable cache hash

# CI Integration
- Attach treemap screenshot or report link to PR, explain growth root cause
- Baseline comparison: compare against main branch at the same config (fetch-depth: 0)
- When over budget, propose specific fixes: lighter API, lazy loading, removing dev deps from prod graph

Back to skills More skills