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).
| 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 |
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.
Search for the same package under different paths or versions; converge resolution or pin one version; use resolve.alias if needed.
Map routes and permissions: narrow chunks to reachable paths; heavy modules should live under import() subtrees, not layout sync chains.
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