Skip to content

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

  1. npm run build emits dist/assets/*.js plus *.js.gz variants (via vite-plugin-compression).
  2. The script scans dist/assets/ for *.js.gz files.
  3. Each file is matched against the BUDGETS array (first matching pattern wins). Each rule is { pattern, budgetKB, name }.
  4. For each matched chunk, it compares the actual gzipped size against the budget.
  5. Output is a table: chunk | rule | actual (KB gz) | budget (KB gz) | status.
  6. 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, or dist/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-xlsx is 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:

ANALYZE=true npm run build
open dist/bundle-stats.html

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:

  1. 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.).
  2. Move the dep into its own vendor chunk via rollupOptions.output.manualChunks in vite.config.ts, so it caches independently and doesn't pollute a route chunk.
  3. Tree-shake imports. Replace import _ from 'lodash' with import debounce from 'lodash/debounce'. Same for Radix — import individual packages, not the meta-package.
  4. 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

npm run build
npm run analyze:bundle

Output ends with either All budgeted chunks within their gzip budgets. or a list of overages with exact KB gz numbers.