Skip to content

Cut per-navigation latency across the dashboard - #11

Merged
pataniaeli merged 1 commit into
mainfrom
dev
Aug 25, 2026
Merged

Cut per-navigation latency across the dashboard#11
pataniaeli merged 1 commit into
mainfrom
dev

Conversation

@pataniaeli

Copy link
Copy Markdown
Collaborator

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 and AuthGuard effects 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, because AuthGuard returned null and 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.tsx nav uses next/link instead of <a>. Navigation is now a client-side transition and routes prefetch. The layout + AuthGuard effects run once per session instead of once per page change, removing ~5 serial round trips per navigation.

Rendering

  • AuthGuard scoped to the content area so the sidebar paints immediately.
  • Guards render a skeleton instead of null.
  • Removed the duplicate AuthGuard on /sga-spaces (the layout already provides one), which had been gating that page's skeleton behind 4 serial auth round trips.

Auth verification

  • New lib/auth.ts getAuthedUser(), backed by getClaims(). Migrated all 82 getUser() call sites.
  • The token and its claims are unchanged. What changed is verification: 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.
  • Middleware no longer runs on /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-rooms returns leadershipBodyIds (it already computed them) — drops a browser-side auth + board_memberships query.
  • Admin counts shared with the layout via context instead of fetched twice per load.
  • Four users-table lookups for admin_role dropped in favour of the JWT claim.
  • Promise.all for independent queries in /api/me/settings, /api/request, and the layout's user check.
  • /sga-spaces stopped refetching remaining-hours on every calendar week change; bookings-tab stopped refetching bodies/semesters on the "show all" toggle.
  • Supabase browser client memoised instead of rebuilt per render.

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

  • Added ephemeralCache; split signupRateLimiter into 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 claiming is_admin: true would have been accepted. getClaims() keeps the same cryptographic guarantee as getUser(). Worth confirming a tampered auth cookie still fails closed.

One genuine behaviour change: admin_role now 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 every is_admin check in the app behaved, so it's consistent rather than new — but it's worth exercising. I verified all 24 users currently have app_metadata.admin_role and is_admin in sync with the users table, including all 6 admins.

Not addressed here: the unbounded / N+1 admin routes (administrator/archive, bookings?all=true, cancellations, requests, and the semesters DELETE chain). Those are correctness-under-growth issues rather than page-change latency, so they were left out of this pass.

Verification

  • npx tsc --noEmit — clean
  • npm run build — succeeds, all 51 routes compile
  • ESLint on the 64 changed files — 3 errors, all pre-existing and identical to baseline (two "variable accessed before declared" on untouched lines, one set-state-in-effect). The bookings-tab change was restructured specifically to avoid adding a fourth.

🤖 Generated with Claude Code

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).
@vercel

vercel Bot commented Aug 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
chambers Ready Ready Preview Aug 25, 2026 9:03pm

@pataniaeli

Copy link
Copy Markdown
Collaborator Author

Tampered auth cookie confirmed as failing, thus secure

@pataniaeli
pataniaeli merged commit 6fc30c6 into main Aug 25, 2026
4 checks passed
@pataniaeli pataniaeli self-assigned this Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant