Performance
This page is a reference for the frontend performance guardrails that the current codebase already enforces, the places where code has drifted from that contract, and the backlog items that are not wired as active telemetry yet.
Current Performance Boundary
Lumie's current App Router shape is not a blanket authenticated SSR-prefetch rollout.
Server reads are concentrated in:
- public tenant and homepage bootstrap such as
app/page.tsx,app/[customId]/page.tsx, andapp/[customId]/layout.tsx - auth, role, onboarding, and metadata gates in
app/admin/layout.tsx,app/dashboard/layout.tsx, andapp/(onboarding)/layout.tsx - a small number of role-checked pages such as
app/admin/settings/billing/page.tsx
Most authenticated pages stay thin server entrypoints that hand off to client feature islands, for example app/admin/students/page.tsx, app/admin/exams/page.tsx, and app/dashboard/assignments/page.tsx.
src/shared/lib/query-client.ts still keeps the server/browser QueryClient split safe, but that does not mean every authenticated screen should grow server prefetch or a new hydration boundary. Add server fetching only when it removes a real waterfall and the page can share one parser and one query-key source between server and client.
Source Paths
| Surface | Source path |
|---|---|
| App Router and root providers | lumie-frontend/app/layout.tsx, lumie-frontend/app/admin/layout.tsx, lumie-frontend/app/dashboard/layout.tsx, lumie-frontend/app/dashboard/DashboardShell.tsx |
| Query client lifecycle | lumie-frontend/src/shared/lib/query-client.ts, lumie-frontend/src/shared/providers/QueryProvider.tsx |
| Loading, error, and narrow Suspense boundaries | lumie-frontend/app/loading.tsx, lumie-frontend/app/error.tsx, lumie-frontend/app/admin/loading.tsx, lumie-frontend/app/admin/error.tsx, lumie-frontend/app/dashboard/loading.tsx, lumie-frontend/app/dashboard/error.tsx |
| Heavy chart/code splitting examples | lumie-frontend/src/features/admin-dashboard/view-dashboard/ui/ChartsSection.tsx, lumie-frontend/src/features/admin-dashboard/view-dashboard/ui/HeroKPICard.tsx, lumie-frontend/src/entities/exam/ui/StudentDetailPanel.tsx, lumie-frontend/src/features/student-detail/view-student-detail/ui/AttendanceTab.tsx, lumie-frontend/src/features/student-detail/view-student-detail/ui/ExamResultsTab.tsx |
| Image policy and avatar URLs | lumie-frontend/next.config.ts, lumie-frontend/src/shared/lib/avatar.ts, lumie-frontend/src/features/profile-settings/profile-avatar/ui/ProfileAvatar.tsx, lumie-frontend/src/shared/ui/PersonCardGrid.tsx |
| Background polling and job tracking | lumie-frontend/app/admin/_components/AdminShell.tsx, lumie-frontend/src/shared/lib/jobTracker.ts, lumie-frontend/src/features/grade-omr/track-omr-grading/lib/OmrJobTrackerProvider.tsx, lumie-frontend/src/features/grade-omr/track-omr-grading/api/queries.ts, lumie-frontend/src/entities/exam/providers/ReportJobTrackerProvider.tsx, lumie-frontend/src/entities/exam/api/queries.ts |
| Chat streaming cleanup | lumie-frontend/src/features/ai-chat/api/stream.ts, lumie-frontend/src/features/ai-chat/api/useStreamMessage.ts |
| Large-list rendering | lumie-frontend/src/features/student-management/list-students/ui/StudentList.tsx, lumie-frontend/src/features/student-management/list-students/ui/StudentListTable.tsx, lumie-frontend/src/features/staff-management/list-staff/ui/StaffList.tsx, lumie-frontend/src/features/staff-management/list-staff/ui/StaffListTable.tsx |
App Router Guardrails
Keep app/ thin.
app/layout.tsxowns only cross-app providers and shell concerns:QueryProvider, auth-modal URL sync, the auth modal, and the global toaster.app/admin/layout.tsxandapp/dashboard/layout.tsxadd route-group concerns such as auth redirects, metadata, and route progress, then hand off to their shells.- Thin server pages that only parse params or do access checks should keep rendering feature islands from
src/instead of accumulating client data orchestration inline.
The current route tree deliberately uses server components for the shell and auth boundary more than for broad authenticated data prefetch. Treat that as the default until a route proves that server fetching removes a user-visible waterfall.
Lazy Charts And Heavy Imports
next.config.ts enables experimental.optimizePackageImports for lucide-react, radix-ui, date-fns, and recharts. Keep that list aligned with real high-fanout packages rather than turning it into a generic catch-all.
For heavy client-only visuals, the baseline pattern is next/dynamic plus a fallback sized to the final slot:
- admin dashboard charts in
ChartsSection.tsx - decorative sparklines in
HeroKPICard.tsx - exam detail charts in
StudentDetailPanel.tsx - student detail charts in
AttendanceTab.tsxandExamResultsTab.tsx
Guardrail:
- split charting and similar heavy UI at the feature boundary, not from the root layout
- use
ssr: falseonly when the component is truly browser-only - make the fallback reserve the final height so chunk loading does not reflow the page
Provider Placement
Provider placement is part of the performance contract because every provider above a route increases the shared render surface.
- root-only providers belong in
app/layout.tsx - route-group providers belong in
app/admin/layout.tsx,app/dashboard/layout.tsx, orapp/dashboard/DashboardShell.tsx - feature-specific SDKs or state bridges should stay at the nearest route or widget that actually needs them
The billing route is a good boundary example today: app/admin/settings/billing/page.tsx does a role check and renders BillingHome, but there is no billing SDK wrapper hoisted into the global admin layout.
Loading, Error, And Suspense Boundaries
The baseline route boundaries already exist at the root, admin, and dashboard levels.
app/loading.tsx,app/admin/loading.tsx, andapp/dashboard/loading.tsxprovide shell-aware skeletonsapp/error.tsx,app/admin/error.tsx, andapp/dashboard/error.tsxkeep failures from bubbling to a blank screenapp/layout.tsxscopesSuspenseonly aroundAuthModalUrlSyncandAuthModal, souseSearchParams()support does not swallow page-level loading or streaming fallbacks
When a new route group has materially different loading cost or recovery UX, give it its own loading.tsx and error.tsx rather than relying only on the root boundary.
Image Optimization Policy
Default policy:
- use
next/imagefor raster assets - provide real
sizesandpriorityonly when the route needs them - keep remote hosts declared in
next.config.ts
DiceBear avatars are the deliberate exception. next.config.ts documents why those SVG URLs stay unoptimized: routing them through the Sharp optimizer rasterizes the SVG, increases in-process work, and can OOM the 256 MiB pod. That exception is reflected in avatar surfaces such as ProfileAvatar.tsx and PersonCardGrid.tsx, both of which render getAvatarUrl(...) results from src/shared/lib/avatar.ts.
Contract drift to track:
- the inspected code still contains non-avatar
unoptimizeduses in landing and media surfaces such assrc/widgets/landing/ui/Hero.tsx,AnalyticsHighlight.tsx,ConvergedPlatform.tsx,ParentReport.tsx, andsrc/shared/ui/PostDetailCard.tsx
Those files do not match the DiceBear-specific rationale in next.config.ts, so treat them as backlog cleanup rather than as permission to use unoptimized broadly.
Background Polling And Job Trackers
Polling should be page-scoped by default. The current deliberate exception is long-running admin job tracking.
app/admin/_components/AdminShell.tsx mounts:
OmrJobStatusSyncReportJobStatusSync
Those daemons are acceptable because the current code also bounds them:
createJobTrackerStore()persists the active job in local storage but discards stale jobs during mergeBACKGROUND_JOB_TRACKING_TIMEOUT_MScaps tracker lifetime at 135 minutesuseOmrJobStatus()stops polling onCOMPLETEDorFAILED, usesenabled: !!statusUrl, and only tightens to1500 msat start or end of the batchuseReportJobStatus()also stops at terminal states and stays on a5000 mscadence
Guardrail:
- every new polling loop needs an enable gate, a terminal-state stop, and a clearly owned side effect on completion
refetchIntervalInBackgroundis opt-in, not the default; in the inspected code it is only turned on foruseAiReportJobStatus()- if the user only needs updates while the page is open, keep the poller page-local instead of mounting it in a shared shell
Chat And SSE Abort Ownership
src/features/ai-chat/api/stream.ts accepts an AbortSignal and passes it through to fetch(). src/features/ai-chat/api/useStreamMessage.ts owns the controller lifecycle:
- abort the previous stream before starting a new one
- abort on unmount
- keep the streamed content in a ref so
onDonecan swap the final message into cache atomically
That ownership split is the guardrail:
- transport helpers should accept
AbortSignal - the hook or component that starts the stream owns cleanup
- silent
AbortErroron replacement or unmount is expected behavior, not a user-facing failure
Large Lists, Memoization, And Virtualization
Current list-heavy screens already use a few important baselines:
- URL-backed list params share one parser with the query key path, for example
StudentList.tsxplusparseStudentListParams(...) - row renders for table-heavy views are memoized in
StudentListTable.tsxandStaffListTable.tsx - mobile and desktop list shells split between card grids and tables instead of forcing one DOM shape everywhere
@tanstack/react-virtual is installed in package.json, but no inspected frontend source imports it yet.
Profile rule:
- row memoization and pagination are the baseline for bounded lists
- when a list can outgrow one page worth of DOM or scroll jank becomes visible, profile first and then prefer
@tanstack/react-virtualover hand-rolled windowing - do not add blanket memoization to small forms or single-card views; save it for repeated row or cell work that actually shows up in profiling or route interaction
Web Vitals And RUM Backlog
The repo already lints against Next's Core Web Vitals rules through eslint.config.mjs, but there is no active frontend Web Vitals or RUM pipeline in the inspected code.
- no
useReportWebVitalshook was found - no Sentry or PostHog client wiring was found
app/error.tsxonly contains a backlog comment about wiring a server-side error tracker later
Treat Web Vitals and Sentry or PostHog RUM as backlog work, not as a current release gate or documented production signal.
Verification
cd lumie-frontend
rg -n "optimizePackageImports|api.dicebear.com|unoptimized|AuthModalUrlSync|loading\\.tsx|error\\.tsx" \
next.config.ts app src
rg -n "dynamic\\(|useOmrJobStatus|useReportJobStatus|BACKGROUND_JOB_TRACKING_TIMEOUT_MS|streamChat|AbortController|memo\\(" \
app src
rg -n "@tanstack/react-virtual|useVirtualizer" package.json src
npx tsc --noEmit
Success means the grep finds the code-splitting, image-policy, boundary, polling, stream-abort, and memoization surfaces described above, the virtualization package is visible in package.json with no current runtime imports unless you add them, and npx tsc --noEmit completes without TypeScript errors.