Bundle budgets
The script scripts/check-bundle-budgets.mjs enforces a per-chunk gzipped size budget on the production bundle. CI runs it after npm run build and fails the PR if a matched chunk exceeds its budget. This gate exists to catch accidental regressions in heavy pages (Finance, Bookings, Groups) before they ship.
How it works
npm run buildemitsdist/assets/*.jsplus*.js.gzvariants (viavite-plugin-compression).- The script scans
dist/assets/for*.js.gzfiles. - Each file is matched against the
BUDGETSarray (first matching pattern wins). Each rule is{ pattern, budgetKB, name }. - For each matched chunk, it compares the actual gzipped size against the budget.
- Output is a table: chunk | rule | actual (KB gz) | budget (KB gz) | status.
- Exit codes:
0— all matched chunks within budget (UNBUDGETED chunks are informational, do not fail the build).1— at least one matched chunk is OVER, ordist/assets/is missing (build wasn't run).
Current budgets
Budgets are set to the current gzipped size plus ~15% headroom so routine churn passes but a real regression (> ~15%) trips the gate.
| Chunk rule | Budget (KB gz) | Notes |
|---|---|---|
Entry (index-*.js.gz) |
36 | App bootstrap + router + shared shell (~30.7 KB actual) |
| Finance | 110 | Bank-rec + cash-flow + GST returns UI (~99.8 KB actual) |
| Bookings | 36 | ~30.4 KB actual |
| Groups | 48 | Pricing + invoices tabs are lazy-loaded (~46.2 KB actual) |
| Hotels | 13 | ~10.7 KB actual |
| AirlineBlocks | 34 | ~29.4 KB actual |
API layer (api-*) |
150 | Shared service layer, imported by most routes (~131 KB actual) |
| vendor-react | 10 | ~7.8 KB actual |
| vendor-radix | 104 | ~90.0 KB actual |
| vendor-supabase | 58 | ~50.2 KB actual |
| vendor-charts | 118 | recharts, used on dashboards only (~102.3 KB actual) |
| vendor-query | 14 | TanStack Query (~11.4 KB actual) |
| vendor-forms | 14 | react-hook-form + resolvers + zod (~11.8 KB actual) |
| vendor-dates | 18 | date-fns + react-day-picker (~15.2 KB actual) |
| vendor-icons | 13 | lucide-react (~11.1 KB actual) |
| vendor-xlsx (lazy) | 160 | Loaded only on export flows (~138.4 KB actual) |
| vendor-sentry | 95 | Includes tracing + replay (~83 KB actual) |
Tip
UNBUDGETED rows are informational. When a new route chunk appears without a matching rule, the table flags it so reviewers can decide whether to add a budget. Add one proactively when you introduce a new top-level page.
Lazy-loading philosophy
Not every chunk should be shrunk. What matters is cost on the initial page load, not the size of a chunk that only loads when the user takes a specific action.
vendor-xlsxis a good example. It is ~138 KB gz and only pulled in when the user exports a spreadsheet. The budget is deliberately loose (160 KB). The win was moving xlsx out of the entry path, not shrinking the lazy chunk.vendor-charts(recharts) is loaded on dashboard/reports only. Budget is set to fit; don't gold-plate by trying to swap it for a smaller library unless dashboards are a measured performance problem.
Warning
Do not tighten the lazy-chunk budgets out of misplaced zeal. A comment above each such rule in scripts/check-bundle-budgets.mjs explains the intent — read it before proposing changes.
When the budget is exceeded
Investigate why first. Raise the budget last.
Step 1 — find the culprit
Build with the visualizer on:
This emits a treemap (gzip + brotli sizes) so you can see exactly what grew.
Also check the diff of the PR against main:
- New
imports of heavy libraries? - A statically-imported module that should have been
await import(...)? - A page that used to be lazy-loaded and is now pulled into the entry?
- A vendor-chunk classification change in
vite.config.ts(manualChunks) that moved mass?
Step 2 — fix the root cause when possible
Common interventions, in decreasing order of leverage:
- Lazy-import the heavy dep. Wrap in
const mod = await import('heavy-lib')at the call site. Use for features that aren't on the happy path (export, rarely-hit admin views, etc.). - Move the dep into its own vendor chunk via
rollupOptions.output.manualChunksinvite.config.ts, so it caches independently and doesn't pollute a route chunk. - Tree-shake imports. Replace
import _ from 'lodash'withimport debounce from 'lodash/debounce'. Same for Radix — import individual packages, not the meta-package. - Swap for a lighter dep only when 1–3 don't help. This is a big change; open a tracking issue rather than stuffing it into an unrelated PR.
Step 3 — raise the budget only as a last resort
If the regression is legitimate (new feature genuinely needed the weight, no lazy-split is possible), raise the budget in the same PR as the code change, with a one-line comment in scripts/check-bundle-budgets.mjs above the rule explaining why. Example of the expected shape:
// actual ~115 KB gz — GSTR-3B generator added 2026-04; no lazy-split possible
// because the form is the default tab.
{ pattern: /^Finance-[A-Za-z0-9_-]+\.js\.gz$/, budgetKB: 130, name: 'Finance' },
Danger
Raising a budget without a comment makes future reviewers rubber-stamp the next bump and the next. Always justify.
Running locally
Output ends with either All budgeted chunks within their gzip budgets. or a list of overages with exact KB gz numbers.