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).

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-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 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 manualChunks put clsx inside vendor-charts, and the entry downloaded all of recharts to get it; manualChunks is 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:

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.