7.1 KiB
Bundle Budgets
Status: active
Owner: web platform
Related: scripts/bundle-budgets.json, scripts/verify-bundle-size.mjs
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/lazy_runtime/application_js/total_js/css/manifest_js. Hard CI gate — failure exits 1. - Per-route, per-vendor and per-lazy-runtime budgets (
scripts/bundle-budgets.json): fine-grained gzip limits for routes, vendor chunks and self-hosted feature runtimes/workers. 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 recursively follows the static imports of the React Router entry and root modules. Dynamic imports stay outside that boot signal.
Self-hosted feature runtimes need a separate signal. lazy_runtime contains Monaco, PDF.js and emitted Web Worker entry points: these bytes replaced executable CDN downloads and are loaded only when the feature or worker is activated. application_js preserves the pre-self-hosting cap for the app itself, while total_js still caps every emitted JavaScript byte including lazy runtimes. This keeps CDN removal from weakening either critical-path or overall artifact controls.
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 },
},
"lazyRuntimeChunks": {
"MonacoEditor.client": { "limitKb": 1000, "warningKb": 950 },
"ts.worker": { "limitKb": 1550, "warningKb": 1520 },
},
}
- 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. - Monaco/PDF.js chunks and
*.worker-*entry points uselazyRuntimeChunks; these budgets make formerly hidden CDN payloads reviewable without treating them as route modules.
Local workflow
# Build apps/web normally and print the per-route table:
pnpm --filter @hivegrid/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 @hivegrid/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.
Gitea workflow
.gitea/workflows/bundle-budget.yml runs on every relevant Gitea pull request. The dedicated workflow keeps the expensive web build outside the default CI lane while still enforcing the aggregate budget.
The workflow:
- Build
@hivegrid/web. - Run
verify-bundle-size.mjs --json. - Download the latest
bundle-baseline-mainartifact (captured bybundle-budget-baseline.ymlonmainpushes). - Render a markdown report via
scripts/render-bundle-budget-comment.mjs. - Upload the JSON and Markdown reports as Gitea Actions artifacts.
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 pull request; the bundle-budget artifact shows the Δ. Reviewers and the 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 recursively measured entry/root static graph still has its own
initial_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
- Report artifact fehlt: check the
Bundle Budgetworkflow run — theBuild webstep may have failed (e.g. server-only import in a client export). - A worker appears as a route: worker filenames must match
*.worker-*(or PDF.js's*.worker.min-*) so the verifier puts them inlazy_runtime; update the narrow matcher when adding a differently named worker entry. - CI run is much slower than
pnpm typecheck: the workflow does a fullpnpm --filter @hivegrid/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.