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).
First-load budget (PRF-005)
After the per-chunk table the script lists every file a member of staff downloads between opening the site and seeing the dashboard — the entry, /auth and /app, each with its static imports and CSS, read from dist/.vite/manifest.json — and fails if their gzip total is over 550 KB (537.9 KB on 26 Sep 2026, down from 741.0 KB). If it fails, the list names what is new on the path: lazy-load it, or raise FIRST_LOAD_BUDGET_KB with a comment. What loads when: Performance.
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-*) |
242 | Shared service layer, imported by most routes (241.4 KB actual on 26 Sep 2026). Each raise, and why, is in the comment beside the rule in scripts/check-bundle-budgets.mjs. The operations screens' /screens/* handler is its own lazy chunk (opsScreens, 2.3 KB) — PRF-010. |
| vendor-react | 58 | React, React DOM, router (~52.1 KB actual; react-dom used to sit in vendor-radix) |
| vendor-radix | 58 | ~50.2 KB actual |
| vendor-ui | 8 | clsx, tailwind-merge, class-variance-authority (~6.9 KB actual) |
| vendor-supabase | 58 | ~50.2 KB actual |
| vendor-charts | 118 | recharts, loaded with the panels that draw charts, never on first load (~102.2 KB actual) |
| vendor-query | 15 | TanStack Query (~13.4 KB actual) |
| vendor-forms | 14 | react-hook-form + resolvers + zod (~11.8 KB actual) |
| vendor-dates | 8 | date-fns only; react-day-picker loads with date inputs (~6.1 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 (lazy) | 95 | Tracing + replay, loaded after idle through src/lib/sentryClient.ts (~87.9 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 with the panels that draw charts 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.- A vendor group must not share a dependency with the entry. The object form of
manualChunksputclsxinsidevendor-charts, and the entry downloaded all of recharts to get it;manualChunksis now a function for that reason. The first-load budget catches this kind of mistake.
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.