Skip to content

perf: get every authenticated page under 500ms - #147

Draft
ohong wants to merge 9 commits into
mainfrom
codex/straude-performance-mission
Draft

perf: get every authenticated page under 500ms#147
ohong wants to merge 9 commits into
mainfrom
codex/straude-performance-mission

Conversation

@ohong

@ohong ohong commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Summary

  • add a production-build Playwright performance harness and PostHog web-vitals RUM instrumentation
  • consolidate authenticated request identity around locally verified ES256 claims and one request-cached profile context
  • remove page query waterfalls, duplicate sidebar/profile work, duplicate message preload, and per-attachment signed-URL requests
  • add private leaderboard/profile-stat snapshots refreshed every 10 minutes by pg_cron, snapshot-first cached readers, a set-based streak function, and strict public/private cache boundaries
  • server-render initial settings, search, card, and recap data; add loading shells for every gating route; keep heavy client dependencies lazy
  • add bundle, database, RUM, baseline, decisions, and goal-loop documentation

Performance result

Two consecutive clean-checkout bun run perf:check runs passed all 10 authenticated routes.

Route TTFB LCP
/feed 35 ms 442 ms
/leaderboard 44 ms 160 ms
/u/[username] 43 ms 466 ms
/post/[id] 36 ms 432 ms
/notifications 35 ms 438 ms
/messages 43 ms 444 ms
/prompts 42 ms 98 ms
/recap 39 ms 434 ms
/settings 43 ms 444 ms
/search 37 ms 430 ms

Right-sidebar API median: 30 ms. Initial authenticated-route JavaScript fell by about 39 KiB gzip after removing the development-only Agentation toolbar from production entry bundles.

Database deployment

The two Supabase migrations in this PR are already applied to project kanfzeovbmusnhmbnhit.

  • 355 leaderboard snapshot rows and 561 profile-stat snapshot rows were populated with one refresh timestamp
  • one refresh-leaderboard-snapshots cron job is active on */10 * * * *
  • anon/authenticated roles cannot read either snapshot table or execute get_profile_stats; service-role access succeeds
  • weekly snapshot read: 0.057 ms and 5 shared-hit blocks, down from the 2.577 ms / 288-block aggregate baseline
  • security and performance advisors returned to their exact pre-change baselines (35 and 45 notices respectively), with no migration-related notices

Verification

  • bun run perf:check twice: 12/12 Playwright checks and 10/10 route gates on both runs
  • full Vitest suite: 86 files, 670 tests passed
  • bun run typecheck: passed
  • bun run build: passed repeatedly, including both final perf runs
  • bun run analyze: passed
  • focused ESLint and migration/privacy/cache tests: passed
  • CLI auth verification and migration-safety suites: passed
  • git diff --check: passed

Post-deploy check

PostHog $web_vitals and custom TTFB reporting are implemented and documented, but production p75 validation remains the non-gating honesty check after deployment.

@vercel

vercel Bot commented Jul 18, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
straude Ready Ready Preview, Comment Jul 18, 2026 10:08pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e8caea20-7d8f-47c7-a2ac-da3a32fbdcbe

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/straude-performance-mission

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ohong

ohong commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review — perf mission

Read the migration, the new data layer, the auth changes, and the perf harness. The shape of this work is right: the expensive aggregate moves to a cron-refreshed private table, the request path reads a bounded snapshot, and DECISIONS.md records the staleness budget up front. Two things need fixing before this leaves draft, and one adjacent item needs a decision recorded.

The refresh_leaderboard_snapshots() transaction is well built, by the way — the DELETE ... WHERE refreshed_at <> v_refreshed_at inside the same transaction as the upsert means readers see either the whole old snapshot or the whole new one, never a half-written mix. And moving calculate_user_streak from SET search_path = public to SET search_path = '' with fully-qualified public. / pg_catalog. references is a real hardening over what's on main.


1. The documented fallback never fires in the failure mode that matters

DECISIONS.md says request paths "retain the existing leaderboard views as a rollout fallback." In lib/data/leaderboard.ts that fallback is gated on snapshot.error:

const snapshot = await snapshotQuery;
if (!snapshot.error) {
  return (snapshot.data ?? []) as LeaderboardRow[];
}

A query that returns zero rows is not an error, so the three realistic failure modes all return a successful empty answer and never reach the view:

  • The cron stops. pg_cron job failures are silent, and refresh_leaderboard_snapshots() starts with pg_try_advisory_xact_lock(...) and RETURNs quietly when it can't take the lock. Nothing on the read side notices. The leaderboard serves frozen numbers indefinitely — and for period = 'day' a frozen snapshot is visibly wrong within hours, since it keeps showing yesterday's leaders.
  • A user isn't in the snapshot yet. queryLeaderboardRank returns null when entry.data is null. A user who just ran their first straude push has no rank on the leaderboard page for up to 10 minutes, even though leaderboard_weekly would show them immediately. That's the exact moment the product most needs to feel responsive.
  • A fresh environment before the first refresh. The migration calls SELECT public.refresh_leaderboard_snapshots(); at line 488, so production is fine on apply — but any environment where that hasn't run renders an empty board rather than falling back.

The fix is cheap because the schema already carries the signal and the reader throws it away: leaderboard_snapshots.refreshed_at is NOT NULL, and LEADERBOARD_SELECT doesn't include it. Select it, treat a snapshot older than roughly two refresh intervals as a miss, and treat a missing user row in queryLeaderboardRank as a miss too. Both then fall through to the live view — a slow correct answer instead of a fast wrong one.

Worth pairing with a refreshed_at staleness alert, since a silent cron death otherwise has no external symptom.

2. Two rank sources now disagree with each other

loadLeaderboardRank reads leaderboard_snapshots (≤10 min refresh, plus revalidate: 600 on top, so ~20 minutes worst case). These readers were not converted and still compute weekly rank live from leaderboard_weekly:

  • apps/web/app/api/users/[username]/route.ts:77-104 — profile global and regional rank
  • apps/web/app/api/cli/dashboard/route.ts:119-137 — the rank the CLI prints, plus the above/below neighbours
  • apps/web/app/api/users/me/route.ts:403
  • apps/web/lib/share-assets/github-card-data.ts:81-119
  • apps/web/app/(landing)/page.tsx:38

So the same user can see rank 5 in the CLI right after a push and rank 7 on /leaderboard, with no way to tell which is right. The CLI path is the sharpest version of this, because straude push immediately followed by the dashboard is the normal loop.

Pick one: convert those readers to the snapshot too (consistent, uniformly ≤10 min stale), or scope snapshots to the leaderboard listing and keep every rank live. Either is defensible; having both is not.

3. getClaims() drops server-side revocation, and the decision record doesn't say so

The getUser()getClaims() switch in lib/supabase/middleware.ts is the right call for latency and the 25-30ms → 0-1ms number justifies it. But local JWKS verification means a signed-out or revoked session keeps being accepted until its access token expires — there's no longer a per-navigation check with the auth server. The DECISIONS.md entry lists signing-key rollout and CLI regression testing as the tradeoff and doesn't mention revocation latency, which is the one with security consequences.

It compounds slightly with getAuthContext in lib/supabase/auth.ts reading the caller's row through getServiceClient(): remote verification and RLS both stop being backstops in the same change. The .eq("id", identity.id) scoping is correct as written, so this is a note about defence in depth, not a bug.

Concretely: state the project's access-token TTL and accept that as the revocation window in DECISIONS.md. This isn't hypothetical — see #5.

4. The perf harness signs in against whatever .env.local points at

e2e/perf/env.ts reads apps/web/.env.local and auth.setup.ts signs in against NEXT_PUBLIC_SUPABASE_URL from it, which in this repo is the production project. It then queries production leaderboard_weekly and posts to pick targets, and writes a real session to e2e/perf/.auth/storage-state.json.

Minting the session at run time from PERF_TEST_EMAIL / PERF_TEST_PASSWORD is the right design, and this PR adds the .gitignore rules that stop the output being committed. Worth one more guard: refuse to run when the Supabase URL resolves to the production project unless an explicit opt-in variable is set. That closes the class rather than the instance.

5. Merge coordination with #149

#149 covers the same ground from the other side: e2e/perf/.auth/storage-state.json and perf-results/ were already committed to main in 62f136a, so ignoring them isn't enough on its own — they have to be untracked, and the session in that file has to be revoked. Note that the leaked file is a refresh token, which stays exchangeable until the session is explicitly revoked; combined with #3, revoking it won't take effect on the app side until the current access token expires.

The two branches will conflict in .gitignore. #149 uses **/.auth/ (catches any future perf directory, not just apps/web/e2e/perf/) plus test-results; this PR uses apps/web/e2e/perf/.auth/. Whichever lands second should keep the broader glob and #149's git rm --cached.


Smaller notes

  • refresh_leaderboard_snapshots() aggregates all of daily_usage every 10 minutes, and toolkit_stats runs a LATERAL jsonb_array_elements over every row with a model_breakdown array. Fine at current volume, grows linearly. Worth a ceiling before it becomes the thing that needs optimising.
  • Cursor pagination uses .lt("total_cost", cursor) with ORDER BY total_cost DESC and no tiebreaker, so users tied on cost get dropped at page boundaries. Pre-existing on main, not introduced here — but idx_leaderboard_snapshots_period_cost is already (period, total_cost DESC, user_id), so adding user_id as the tiebreaker is free now.
  • calculate_user_streak keeps GRANT EXECUTE ... TO anon, which lets anyone call the RPC with an arbitrary user id and read a streak for a private profile. Pre-existing from 20260430172022_fix_calculate_user_streak_security_definer.sql, not this PR's doing, and this PR improves the function otherwise. Flagging it for a separate issue.
  • env.ts matches env lines with ^([A-Z_0-9]+)=(.*)$, so a lowercase key or an export -prefixed line is silently skipped. Only matters if someone's .env.local uses either.

No commits pushed to this branch. It's an active 82-file draft with its own remaining acceptance criteria listed in DECISIONS.md (second clean scorecard run, production migration apply, post-deploy $web_vitals), and pushing into it would create conflicts for you rather than save time. #1 and #2 are the two I'd want closed before this merges; #1 is contained to lib/data/leaderboard.ts.

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