Cut per-navigation latency across the dashboard - #11
Merged
Merged
Conversation
Page changes were doing a full app cold boot. Root cause was the sidebar nav rendering plain <a> tags, which forces a full document navigation and re-runs every mount-time auth effect on every click. Client: - layout: nav links use next/link, so navigation is client-side and routes prefetch. The layout/AuthGuard effects now run once per session instead of once per page change. - layout: AuthGuard scoped to the content area so the sidebar paints immediately rather than the whole app staying blank during the check. - guards: render a skeleton instead of returning null. - sga-spaces: drop the duplicate AuthGuard (the layout already provides one), and stop refetching remaining-hours on every calendar week change. - my-rooms: /api/my-rooms now returns leadershipBodyIds, removing a browser-side auth + board_memberships round trip. - administrator: share counts with the layout via context instead of fetching the same endpoint twice per load. - bookings-tab: stop refetching bodies/semesters on the "show all" toggle. - memoise the Supabase browser client instead of rebuilding it per render. Auth: - add lib/auth.ts getAuthedUser(), backed by getClaims(). This project signs with ES256, so the JWT is verified locally against a cached JWKS rather than making a network call to the Auth server on every check. Migrated all 82 getUser() call sites. Signatures are still verified -- this is not getSession(). - middleware no longer runs on /api/**; every route authenticates itself, so that hop was a discarded round trip per API call. - drop 4 users-table lookups for admin_role, which is already a JWT claim. Server: - waitUntil() for post-commit emails (space booking confirm/cancel, blackout cascades, admin booking updated/missed) so users stop waiting on Resend. - Promise.all independent queries in /api/me/settings and /api/request. - rate limiter: add ephemeralCache; split signupRateLimiter into its own module so ~40 routes stop constructing a second Redis client. Also includes the two RLS migrations applied earlier (auth initplan wrap, permissive policy consolidation).
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Collaborator
Author
|
Tampered auth cookie confirmed as failing, thus secure |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Changing pages felt slow. The root cause was that the sidebar nav rendered plain
<a>tags, which forces a full document navigation. That tore down the SPA and cold-booted the app on every click — and because the layout andAuthGuardeffects have[]deps, a full reload is the one thing that makes them run again.So every page change paid: full HTML request → re-parse and re-hydrate ~900KB of JS →
AuthGuard(2 serial Supabase round trips) → layout (3 more) → only then does the page mount and start its own fetches. All of it behind a blank screen, becauseAuthGuardreturnednulland wrapped the entire layout including the sidebar.Measured against production before the change: an API route returning a bare 401 cost 70–185ms of pure overhead before doing any work. Supabase Auth round trips ran ~95–110ms warm (350ms cold); PostgREST queries ~190–380ms. Data volume was never the issue — every table has between 1 and 61 rows.
What changed
Navigation (the fix that matters most)
layout.tsxnav usesnext/linkinstead of<a>. Navigation is now a client-side transition and routes prefetch. The layout +AuthGuardeffects run once per session instead of once per page change, removing ~5 serial round trips per navigation.Rendering
AuthGuardscoped to the content area so the sidebar paints immediately.null.AuthGuardon/sga-spaces(the layout already provides one), which had been gating that page's skeleton behind 4 serial auth round trips.Auth verification
lib/auth.tsgetAuthedUser(), backed bygetClaims(). Migrated all 82getUser()call sites.getUser()sent the JWT to the Auth server on every check;getClaims()verifies the signature locally against a cached JWKS. This project signs with ES256, which is what makes local verification possible./api/**. Every route authenticates itself, so that hop was a discarded round trip per API call. Cookie refresh still runs on page navigation.Round trips removed
/api/my-roomsreturnsleadershipBodyIds(it already computed them) — drops a browser-side auth +board_membershipsquery.users-table lookups foradmin_roledropped in favour of the JWT claim.Promise.allfor independent queries in/api/me/settings,/api/request, and the layout's user check./sga-spacesstopped refetching remaining-hours on every calendar week change;bookings-tabstopped refetching bodies/semesters on the "show all" toggle.Blocking work deferred
waitUntil()for post-commit emails: space booking confirm/cancel, blackout cascades, and the admin booking updated/missed paths. Users no longer wait on Resend for work that happens after the write is committed.Rate limiting
ephemeralCache; splitsignupRateLimiterinto its own module so ~40 routes stop constructing a second Redis client at import.Also includes the two RLS migrations applied earlier this session (auth-function initplan wrapping, permissive-policy consolidation), both verified against the advisors.
Reviewer notes
The auth change is the security-sensitive one. I deliberately did not use
getSession(), which would also have removed the network hop — it returns claims without verifying the signature, so a forged cookie claimingis_admin: truewould have been accepted.getClaims()keeps the same cryptographic guarantee asgetUser(). Worth confirming a tampered auth cookie still fails closed.One genuine behaviour change:
admin_rolenow reads from the token rather than a live DB read, so a role change takes effect on the user's next token refresh instead of instantly. This already matched how everyis_admincheck in the app behaved, so it's consistent rather than new — but it's worth exercising. I verified all 24 users currently haveapp_metadata.admin_roleandis_adminin sync with theuserstable, including all 6 admins.Not addressed here: the unbounded / N+1 admin routes (
administrator/archive,bookings?all=true,cancellations,requests, and thesemestersDELETE chain). Those are correctness-under-growth issues rather than page-change latency, so they were left out of this pass.Verification
npx tsc --noEmit— cleannpm run build— succeeds, all 51 routes compileset-state-in-effect). Thebookings-tabchange was restructured specifically to avoid adding a fourth.🤖 Generated with Claude Code