6.7 KiB
Bundle Budgets
Status: active
Owner: web platform
Related: scripts/bundle-budgets.json, scripts/verify-bundle-size.mjs, docs/improvement-plan/10-compliance-polish/bundle-budget.md
Why
Web bundle growth is silent unless we measure it. The boilerplate tracks two complementary signals:
- Group budgets (
BUDGETSconstant inverify-bundle-size.mjs): coarse caps forroot/initial_payload_js/vendor/route/other/css/total_js/manifest_js. Hard CI gate — failure exits 1. - Per-route + per-vendor-chunk budgets (
scripts/bundle-budgets.json): fine-grained gzip limits per route and per vendor chunk. Non-blocking — surfaces as a PR-comment with a Δ-from-main column. Reviewers decide whether growth is justified.
The per-route layer was added in task #71 (Phase 10) to make day-to-day creep visible without blocking legitimate feature work.
initial_payload_js is a dedicated hard signal for the boot asset aggregate the current parser can identify without reading Rollup/Vite dependency graphs: entry.client-*, root-*, and manifest-*. It intentionally excludes route, vendor, and component chunks so lazy feature payloads cannot hide a shell regression in total_js.
Config schema
scripts/bundle-budgets.json validated by scripts/bundle-budgets.schema.json:
{
"default": { "limitKb": 200, "warningKb": 180 },
"routes": {
"app/$tenant/dashboard": { "limitKb": 250, "warningKb": 225 },
"auth/magic-link": { "limitKb": 80, "warningKb": 75 }
},
"vendorChunks": {
"vendor-react": { "limitKb": 150, "warningKb": 135 }
}
}
- All sizes are gzip kilobytes (the transport-relevant number).
limitKbis the hard cap; assets above this report[EXCEED]in CI output and the PR comment.warningKbis the soft threshold; betweenwarningKbandlimitKbassets report[WARN].- Routes use React Router v7 route ids in either dot- or slash-form (
auth/magic-linkandauth.magic-linkboth match). - Unmatched routes fall back to
default. - Unmatched vendor chunks (anything starting with
vendor-orchunk-) are reported under the chunk basename and also fall back todefault.
Local workflow
# Build apps/web normally and print the per-route table:
pnpm --filter @toolctrl/web build
node scripts/verify-bundle-size.mjs
# Only the per-route table (skips group checks):
node scripts/verify-bundle-size.mjs --per-route-only
# Machine-readable JSON (used by the PR-comment workflow):
node scripts/verify-bundle-size.mjs --json > pr-bundle-report.json
--json emits only the non-blocking per-route/per-vendor report and exits with
code 0 when the report was produced. Keep the hard group-budget gate on the
plain node scripts/verify-bundle-size.mjs path so redirected JSON never mixes
machine-readable output with human diagnostics.
Visualizer (treemap)
To inspect why a chunk is big:
pnpm --filter @toolctrl/web run bundle:visualize
open apps/web/build/stats.html
The visualizer is gated on ANALYZE_BUNDLE=true so the regular production build keeps its current footprint. It uses rollup-plugin-visualizer (treemap, gzip + brotli sizes per chunk).
Security note: stats.html contains source-map references and should never be deployed publicly. Treat it as a developer-only artifact.
PR-comment workflow
.github/workflows/bundle-budget.yml (and .gitea/workflows/bundle-budget.yml) run on every PR that touches apps/web/**, packages/**, or the budget config / verifier. They:
- Build
@toolctrl/web. - Run
verify-bundle-size.mjs --json. - Download the latest
bundle-baseline-mainartifact (captured bybundle-budget-baseline.ymlonmainpushes). - Render a sticky markdown comment via
scripts/render-bundle-budget-comment.mjs. - Upsert the comment using its
<!-- bundle-budget-report -->marker (GitHub:marocchino/sticky-pull-request-comment; Gitea:scripts/post-bundle-budget-gitea-comment.mjs).
The comment renders rows in severity order ([EXCEED] → [WARN] → unbudgeted → ok), with columns: status / kind / route or chunk key / current gzip size / configured limit / Δ from main. Δ is — if no baseline is available.
When the baseline is missing
The first PR after a fresh checkout (or after a baseline-artifact expiry) sees Δ from main = — for every row. This is expected — the next push to main re-captures the baseline.
When to raise a limit
- Confirm the growth is intentional (a new feature shipped, a dependency landed) — not a regression from accidental imports.
- Open the PR; the bundle-budget comment shows the Δ. Reviewers and the PR author should agree the new floor is acceptable.
- Edit
scripts/bundle-budgets.jsonin the same PR that ships the growth. BumpwarningKbto ≈ (current gzip + 5 KB) andlimitKbto ≈ (current gzip + 20 KB). - Call the change out in the PR description so the new floor is reviewable.
Anti-patterns
- Don't disable a budget by raising it to 9999 KB. The point is to surface drift. If a route is legitimately huge, raise the limit to a tight new floor, not infinity.
- Don't lazy-load critical-path code. Bundle splits help non-critical routes, but the initial payload (
entry.client-*/root-*/manifest-*) still has its owninitial_payload_jshard group budget that must stay tight. - Don't bundle-split shared deps. Splitting
reactinto many chunks just shifts cost into chunk boot-up. Keepvendor-react,vendor-core-utilsetc. as single chunks; the manualChunks mapping inapps/web/app/utils/build/manualChunks.tsis the source of truth.
Troubleshooting
- PR comment never shows up: check the
Bundle Budgetworkflow run — theBuild webstep may have failed (e.g. server-only import in a client export). The comment job only runs if all upstream steps succeed. vendor-monaco-...reports asvendor-monaco-...-with default budget: rollup occasionally emits chunk hashes ending in-; the baseline normalizer skips that suffix and the asset falls back todefault. Either rename viamanualChunks.tsor add an explicit budget entry.- CI run is much slower than
pnpm typecheck: the workflow does a fullpnpm --filter @toolctrl/web build. That's the expected cost. If it ever exceeds ~30 minutes, profile withANALYZE_BUNDLE=truelocally first.
Rollback
- If the per-route budget surfaces too many false alarms, lower the warning thresholds in
scripts/bundle-budgets.jsonand ship a follow-up. - If the entire PR-comment flow is broken, set
if: falseon themeasurejob inbundle-budget.yml— no other CI stage depends on it.