# Performance budget > **Audience:** frontend maintainers and PR reviewers. > **Scope:** the bundle-size budget per role group, how to measure it, and > the role-aware lazy-loading design. This document is the contract that protects the offline-first PWA from back-sliding into a single bloated bundle. Every CI / merge gate that touches `client/src/App.tsx` or `client/src/pages/lazy/*` should run a build and verify the budgets below are still met. ## 1. The lazy-loading architecture `client/src/App.tsx` declares 12 role-route groups as `React.lazy()` imports, one per role that has UI pages: ``` const AdminRoutes = React.lazy(() => import('./pages/lazy/admin/index')); const TeacherRoutes = React.lazy(() => import('./pages/lazy/teacher/index')); const StudentRoutes = React.lazy(() => import('./pages/lazy/student/index')); const ParentRoutes = React.lazy(() => import('./pages/lazy/parent/index')); const HrRoutes = React.lazy(() => import('./pages/lazy/hr/index')); const LibrarianRoutes = React.lazy(() => import('./pages/lazy/librarian/index')); const BursarRoutes = React.lazy(() => import('./pages/lazy/bursar/index')); const PrincipalRoutes = React.lazy(() => import('./pages/lazy/principal/index')); const ClubsHeadRoutes = React.lazy(() => import('./pages/lazy/clubs_head/index')); const SystemsAdminRoutes = React.lazy(() => import('./pages/lazy/systems_admin/index')); const NurseRoutes = React.lazy(() => import('./pages/lazy/nurse/index')); const DiningStaffRoutes = React.lazy(() => import('./pages/lazy/dining_staff/index')); ``` `LazyRoleGroup` wraps each lazy role in a `}>` inside a ``. The `LoadingFallback` renders a centered spinner without shifting the surrounding layout — the Nav and shell remain in place while the chunk streams in. `client/src/components/Nav.tsx` pre-fetches the role chunk on `onMouseEnter` via `prefetchRoleChunk(role)`. Because the user's intent is observable before they click, a `teacher@school.com` user hovering the sidebar link triggers the network round-trip in advance — by the time they click, the chunk is usually already in memory. ## 2. The barrel files Each `client/src/pages/lazy//index.ts` re-exports every page for that role. They are the unit of lazy loading — Vite produces one output chunk per barrel plus shared chunks for any cross-barrel pages (e.g. an `` page imported from the admin barrel). A barrel looks like this (admin example): ```ts export { default as AdminDashboard } from '../../pages/dashboard/AdminDashboard'; export { default as HRManagement } from '../../pages/admin/HRManagement'; export { default as Students } from '../../pages/admin/Users'; // …and so on for every admin page ``` When adding a new admin page, add a line here — **the lazy role won't find the page otherwise**. Then add it to the `` route list in `App.tsx`. ## 3. The budgets After `cd client && npm run build`, the role group's total JS payload (every `.js` chunk imported transitively from that role's first navigation plus the shared vendor chunks) should sit under the budget below. These are deliberately loose to avoid blocking refactors — the goal is to catch regressions in the 100KB+ range, not forbid incremental growth. | Role | Per-role JS budget | Vendor share counted? | |------------------|---------------------|------------------------| | Admin (school) | **≤ 800 KB** | shared vendor chunks split pro-rata across the page chunks | | Teacher | **≤ 600 KB** | same | | Student | **≤ 500 KB** | same | | Parent | **≤ 400 KB** | same | | HR / Bursar / Librarian / Clubs head / Principal | (no budget — inherit from Admin / Teacher / Student) | — | The split-pro-rata method: list every chunk transitively reached from `import('./pages/lazy//index')`, sum their `kB` values. If the sum is above the budget, either split a heavy page into a deeper lazy chunk or move a vendored library out of `manualChunks` into a per-page import. ## 4. How to measure ```bash cd client npm run build # default: full bundle npm run build:analyze # vite-bundle-visualizer in browser ``` `build:analyze` runs `vite build --mode analyze` and pipes the output through `vite-bundle-visualizer`. Open the treemap and look for any of: - A single chunk > 200 KB that isn't a vendor chunk — likely a candidate for further lazy splitting. - A vendor chunk pulling in a library page-X doesn't need — adjust `vite.config.ts` `manualChunks`. - A duplicated dependency (e.g. two `recharts` instances from different lazy paths) — usually a sign of mismatched imports. ## 5. Manual smoke test After any change to `client/src/App.tsx`, `vite.config.ts`, or `client/src/pages/lazy/*`: 1. `cd client && npm run build` — must succeed with the budget gates above. 2. `cd client && npx playwright test e2e/lazy-load.spec.ts` — 4 specs: - admin dashboard loads without errors. - teacher dashboard loads without errors. - student dashboard loads without errors. - parent dashboard loads without errors. 3. Open DevTools → Network → throttling to "Slow 3G", reload `/dashboard/student`. The spinner should appear within 200ms and the chunk should land within 1s. The Nav and shell should be stable throughout (no layout shift). ## 6. Common regressions to watch for | Symptom | Likely cause | |---|---| | A page chunk explodes to 500+ KB | A lazy page added a direct import of a heavy library (e.g. `recharts`). Move the import inside the page's local render or split it further. | | `npm run build` reports a circular dependency | Two barrels re-export each other's pages. Restructure: barrels re-export directly-imported pages, not other barrels. | | `npm run build` fails with "Unexpected closing tag" near a `` | A modal migration left mismatched `` instead of ``/``. See `scripts/lint-no-inline-modals.js` for a regression-net script. | | A role's first navigation takes > 3s on a school LAN | The pre-fetch in `Nav.tsx` was removed, or an env header is blocking the chunk. Re-add `onMouseEnter={() => prefetchRoleChunk(role)}` to the link. | ## 7. Related files - `client/src/App.tsx:11-22` — the 12 `React.lazy()` declarations. - `client/src/App.tsx:53-67` — `LazyRoleGroup` wrapper. - `client/src/components/LoadingFallback.tsx` — spinner fallback. - `client/src/components/Nav.tsx:332-336, 441, 558` — pre-fetch on hover. - `client/vite.config.ts:91-102` — `manualChunks` (react / zustand / recharts / lucide). - `client/src/pages/lazy//index.ts` — 12 barrel files; one per active role. - `client/e2e/lazy-load.spec.ts` — 4 smoke specs.