Skip to content

Performance: what loads when

What the browser downloads, when it downloads it, and what it keeps. The rule is PRF-005: the first screen downloads only what it shows, under a budget CI enforces.

First load: sign-in to dashboard

A member of staff opening the site downloads the entry (index.html and what it imports), the /auth screen and the /app role dashboard. Measured from a production build on 26 Sep 2026, gzip:

Before After
Files 28 25
Raw 2,700 KB 2,018 KB
Gzip 741.0 KB 537.9 KB

What was taken off the path:

Removed from first load Gzip Now loads
Sentry, with tracing and replay 88 KB after the browser is idle (src/lib/observability.ts)
recharts 102 KB with the report panels that draw charts
react-day-picker (was grouped with date-fns) 9 KB with date inputs
Two-step sign-in screen and its OTP input 6 KB when a TOTP code is asked for

Why recharts was there: vite.config.ts used the object form of manualChunks, which also pulls every dependency of the listed packages into the group. recharts depends on clsx, so clsx landed in vendor-charts, and the entry — which needs clsx for every class name — had to download the whole charts chunk to get it. manualChunks is now a function that groups a package's own files only, and the small class-name helpers have their own vendor-ui chunk.

What is still on the path, largest first:

Chunk Gzip Why
api-* (src/lib/api.ts) 236 KB Every screen imports the API layer. It is 44% of first load and the next win; splitting it by module is tracked under the API layer budget in scripts/check-bundle-budgets.mjs.
vendor-supabase 57 KB Sign-in and every read.
vendor-react 52 KB React, React DOM, the router.
vendor-radix 50 KB Dialog, dropdown, tooltip, toast and the other primitives the shell uses. Splitting it per package saved 8 KB and lost long-term caching of the group; not done.
index-* (entry) 31 KB Providers, router table, auth.
CSS 24 KB One Tailwind stylesheet.

Not done, and said so here:

  • Fonts. /fonts/Moolboran-Regular.ttf (335 KB) and four Orkney .otf weights (~51 KB each) are served as-is. The sidebar and dashboard use both families, so they download on first load; they are not in the JS budget. Converting them to WOFF2 would roughly halve them. It needs a check of each font's licence first.
  • The API layer is not split (above).

The budget

node scripts/check-bundle-budgets.mjs, run in CI after npm run build, now does two things:

  1. Per-chunk budgets — unchanged; see Bundle budgets.
  2. First-load budget — reads dist/.vite/manifest.json (build.manifest: true in vite.config.ts), walks the static imports of index.html, src/pages/auth/Auth.tsx and src/pages/Index.tsx, adds their CSS, and sums the .gz sizes. Over 550 KB fails the build. Dynamic import()s are not counted: they are not on the path to first paint.

The script prints every file on the path with raw and gzip size, so a failure names what was added.

One request per screen

A screen paints from one request to the database — PRF-010. How the screens are built that way, and which ones are, is on A screen is one call.

After first paint

What When Where
Every other screen when opened; React.lazy with the RouteSkeleton fallback src/App.tsx
The screen behind a sidebar link on hover or keyboard focus of the link preloadRoute() in src/lib/routePreload.ts, called from Sidebar.tsx
Bookings, Groups, Customers, Visa, Work inbox once the browser is idle after sign-in, only those in the person's own menu, and not when the browser asks to save data preloadRoutesWhenIdle(), Sidebar.tsx
Sentry idle, or at once if an error is reported first src/lib/observability.ts → src/lib/sentryClient.ts
Excel (xlsx, xlsx-js-style) inside the import/export handler, by await import() src/services/*Service.ts, src/lib/export*.ts
Passport scanner (tesseract.js) when the scanner opens src/components/common/MrzScanner.tsx
Query persister after first paint src/lib/queryClient.ts
Finance: the intelligence and bank-reconciliation tabs when the tab is opened; React.lazy inside Finance.tsx (Finance chunk 126.5 → 118.7 KB gzip) src/pages/finance/Finance.tsx
Rarely used API routes: day book, P&L, cash flow; TDS; GSTR-1/3B; pending settlements, AP aging, ledger-wise settlements, cancellation report, receivables vs payables; inventory P&L/utilisation/status and group status; customer and agent ledgers; booking import on the first request to the route, by await import() in apiFetch (API chunk 243.7 → 225.0 KB gzip) src/lib/lazyRoutes/ — financeStatements.ts, tdsRoutes.ts, gstReturns.ts, settlementReports.ts, inventoryReports.ts, partyLedgers.ts, bookingImport.ts

Before this change, five screens (Bookings, Finance, Groups, Customers, Visa) were fetched on idle for every visitor — including the public website and customers — whether or not they could open them. Finance alone is 126 KB gzip. That idle fetch is gone.

Requests per screen

How many requests a screen sends one after another matters as much as what it downloads (PRF-010). The finance screens read through one database call each: finance_screen(p_sections) behind GET /finance/screen?parts=…, and finance_journal_screen, finance_ledger_screen, finance_receivables_payables_screen behind their routes (Finance API). The first-screen query is shared by Finance.tsx and the Chart of Accounts under one React Query key (src/lib/financeScreen.ts), so the two send one request, and Finance() starts it beside the PIN check. src/test/financeRoundTrips.test.ts counts the requests and the waits.

Caching

Service worker (src/sw.ts)

Request Strategy
Supabase (*.supabase.co, /rest/v1/, /auth/v1/, /functions/v1/, /storage/v1/) Network only, never cached. Answers carry customer, passport and finance data. The supabase-api cache earlier builds kept is deleted on activate.
The shell's JS and CSS Precached at install. The heaviest lazy chunks are left out (vite.config.ts → globIgnores: xlsx, charts, Sentry, Finance, the India locations list), so a release no longer makes every browser download ~3 MB it may never use. Precache went from 227 files / 7.7 MB to 225 files / 4.8 MB.
Any other /assets/* file Cache first. Names carry a content hash, so a cached copy is never stale. An HTML answer (the host's fallback for a missing file) is never cached; src/lib/staleBuild.ts reloads the tab instead.
/fonts/* Cache first, one year.
Images Cache first, 30 days (unchanged).
Page navigations Network first, 5 s timeout (unchanged).

In memory (React Query, src/lib/queryClient.ts)

Setting Value Effect
staleTime 30 s Reopened within 30 s: shown from memory, no request. Later: shown at once, refreshed in the background. A query can set its own.
gcTime 10 min A screen left for up to 10 minutes comes back without a loading state.
refetchOnWindowFocus true Only a stale query refetches when the tab regains focus.
retry 1
Paged lists placeholderData: keepPreviousData A new filter keeps the current rows on screen until its first page arrives (usePagedList, usePagedQuery; isPlaceholderData says so).

usePagedList used to pass staleTime: undefined when a screen gave none, which React Query reads as 0 — every such list refetched on every mount. It now leaves the option out and gets the 30 s default.

Most screens still load their data with useEffect and the API layer, not React Query, so these settings reach only the screens that use useQuery, usePagedList or usePagedQuery — and the one-call screens of PRF-010, which keep their one answer in the cache (A screen is one call).

On disk (localStorage)

@tanstack/react-query-persist-client with the synchronous localStorage persister, loaded after first paint. localStorage rather than IndexedDB: the data is a few hundred bytes, and the synchronous persister needs no extra library.

Only query roots in PERSISTED_QUERY_ROOTS are written. Today that is badge-counts (the sidebar counters, keyed by user). No booking, customer, passport, document, finance or dashboard query is ever written to disk; a test (src/lib/queryClient.test.ts) fails if the allow-list gains one. Stored data expires after 24 hours and is dropped by a new release. Sign-out clears the stored copy and the whole in-memory cache, so the next person at the same browser never sees the last person's data.

Perceived speed

  • The main lists show skeleton rows in the shape of the table instead of a centred spinner on first load: Customers, Groups, Visa, Suppliers, Business partners, Hotels, Airline blocks (ListPageSkeleton), Leads and Work inbox (DataTable loading), Ticketing departures (TableSkeleton). Bookings and the dashboard are not changed here.
  • A refresh keeps the rows already shown.

Checking it yourself

npm run build
node scripts/check-bundle-budgets.mjs   # per-chunk table, then the first-load list and total
ANALYZE=true npm run build              # treemap at dist/bundle-stats.html