6.7 KiB
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 <Suspense fallback={<LoadingFallback />}>
inside a <ProtectedRoute allowedRoles=...>. 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/<role>/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 <HRManagement>
page imported from the admin barrel).
A barrel looks like this (admin example):
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 <AdminRoutes> 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/<role>/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
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.tsmanualChunks. - A duplicated dependency (e.g. two
rechartsinstances 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/*:
cd client && npm run build— must succeed with the budget gates above.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.
- 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 <Modal> |
A modal migration left mismatched </div> instead of </ModalBody>/</Modal>. 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 12React.lazy()declarations.client/src/App.tsx:53-67—LazyRoleGroupwrapper.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/<role>/index.ts— 12 barrel files; one per active role.client/e2e/lazy-load.spec.ts— 4 smoke specs.