Skip to main content

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, and app/[customId]/layout.tsx
  • auth, role, onboarding, and metadata gates in app/admin/layout.tsx, app/dashboard/layout.tsx, and app/(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

SurfaceSource path
App Router and root providerslumie-frontend/app/layout.tsx, lumie-frontend/app/admin/layout.tsx, lumie-frontend/app/dashboard/layout.tsx, lumie-frontend/app/dashboard/DashboardShell.tsx
Query client lifecyclelumie-frontend/src/shared/lib/query-client.ts, lumie-frontend/src/shared/providers/QueryProvider.tsx
Loading, error, and narrow Suspense boundarieslumie-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 exampleslumie-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 URLslumie-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 trackinglumie-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 cleanuplumie-frontend/src/features/ai-chat/api/stream.ts, lumie-frontend/src/features/ai-chat/api/useStreamMessage.ts
Large-list renderinglumie-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.tsx owns only cross-app providers and shell concerns: QueryProvider, auth-modal URL sync, the auth modal, and the global toaster.
  • app/admin/layout.tsx and app/dashboard/layout.tsx add 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.tsx and ExamResultsTab.tsx

Guardrail:

  • split charting and similar heavy UI at the feature boundary, not from the root layout
  • use ssr: false only 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, or app/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, and app/dashboard/loading.tsx provide shell-aware skeletons
  • app/error.tsx, app/admin/error.tsx, and app/dashboard/error.tsx keep failures from bubbling to a blank screen
  • app/layout.tsx scopes Suspense only around AuthModalUrlSync and AuthModal, so useSearchParams() 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/image for raster assets
  • provide real sizes and priority only 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 unoptimized uses in landing and media surfaces such as src/widgets/landing/ui/Hero.tsx, AnalyticsHighlight.tsx, ConvergedPlatform.tsx, ParentReport.tsx, and src/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:

  • OmrJobStatusSync
  • ReportJobStatusSync

Those daemons are acceptable because the current code also bounds them:

  • createJobTrackerStore() persists the active job in local storage but discards stale jobs during merge
  • BACKGROUND_JOB_TRACKING_TIMEOUT_MS caps tracker lifetime at 135 minutes
  • useOmrJobStatus() stops polling on COMPLETED or FAILED, uses enabled: !!statusUrl, and only tightens to 1500 ms at start or end of the batch
  • useReportJobStatus() also stops at terminal states and stays on a 5000 ms cadence

Guardrail:

  • every new polling loop needs an enable gate, a terminal-state stop, and a clearly owned side effect on completion
  • refetchIntervalInBackground is opt-in, not the default; in the inspected code it is only turned on for useAiReportJobStatus()
  • 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 onDone can 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 AbortError on 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.tsx plus parseStudentListParams(...)
  • row renders for table-heavy views are memoized in StudentListTable.tsx and StaffListTable.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-virtual over 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 useReportWebVitals hook was found
  • no Sentry or PostHog client wiring was found
  • app/error.tsx only 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.