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.otfweights (~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:
- Per-chunk budgets — unchanged; see Bundle budgets.
- First-load budget — reads
dist/.vite/manifest.json(build.manifest: trueinvite.config.ts), walks the static imports ofindex.html,src/pages/auth/Auth.tsxandsrc/pages/Index.tsx, adds their CSS, and sums the.gzsizes. Over 550 KB fails the build. Dynamicimport()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.