Website Performance Audit — 2026-07-30¶
A follow-on pass over the tree after the 2026-07-17 full-stack audit and the 2026-07-29 3D audit. Both of those were largely implemented, so this pass deliberately looked only for things they did not cover, and every finding below was re-verified against the code as it exists today.
Read the two prior documents first — they are still accurate and their “already healthy / do not re-fix” lists still hold:
performance-audit-2026-07-17.md— DB indexes, FTS search paths, bundle splitting, SSR i18n, virtualized timelines, serving topology.3d-performance-audit.md— the WebGL games: render tiers, DPR clamps, instancing/culling, bloom resolution, glTF conversion.
Method. Four sweeps, each mechanical rather than by inspection, so the result is a complete set for its category rather than a sample:
Every
@relation(fields: [...])column inschema.prismachecked against whether it leads any index, then cross-referenced against the queries that actually filter on it (scripts in the commit message trail; they are throwaway).Every repeating client timer (
setInterval+requestAnimationFrame) and what it costs per minute.Every
getComputedStylecall site, checked for whether it sits in a frame loop.Every
findManywithout atake.
What shipped in this pass¶
# |
Change |
Effect |
Where |
|---|---|---|---|
1 |
|
idle signed-in tab: 5 → 2 requests/min, 4 → 1 session resolutions |
|
2 |
Notification writes no longer awaited on like/unlike/follow |
~3 fewer serial queries in front of the response on the hottest write path |
|
3 |
Comment notification block deferred as a unit |
~4–12 fewer serial queries in front of the comment response |
|
4 |
Hashtag linking batched |
3N → 5 fixed queries, and the post-create transaction closes far sooner |
|
5 |
Five missing predicate indexes |
two per-viewer seq scans per presence poll, plus the notification dedupe, removed |
|
6 |
|
removes 1 + N forced style recalcs per frame (N = visible hold notes) |
|
7 |
Song comment list bounded |
removes an unbounded public payload |
|
8 |
Presence answered from Redis; the two fan-outs share work |
fixes a real “shows offline while online” bug; no DB read at all on the Redis path |
|
9 |
Three genuinely unbounded queries capped |
incoming ranked challenges and Discord guild leaderboards no longer grow unbounded |
|
10 |
RMHTube + RMHMusic chat scrollback bounded, memos fixed |
client chat no longer grows for the room’s lifetime or re-sorts on playback ticks |
|
11 |
Canvas-2D probe committed; blurred shadows put behind a tier |
slice-it: 10 → 0 blurred-shadow activations/frame on low-end + reduced motion |
|
12 |
|
zero per-frame work/allocation for theme detection, on every page |
|
Verified: tsc --noEmit clean, eslint 0 errors and no new warnings
(checked file-by-file against the pre-change baseline), vitest run 252 files /
4600 tests green, production vite build green, and the migration applied to a
real PostgreSQL 16 with all five indexes confirmed and no migrate status drift.
Items 1–7 were the first pass; 8–12 are the follow-up described under “Second pass” below, which also corrects two findings the first pass overstated.
1. The idle tab made five authenticated requests a minute¶
Each of these was individually well behaved — ref-counted module singleton, idle-deferred, visibility-aware — but none of them knew about the others, so a signed-in tab with the shell mounted ran five timers and the server resolved the session five times a minute before doing any actual work:
Timer |
Interval |
Owner |
|---|---|---|
|
60s |
|
|
45s |
|
|
60s |
|
|
60s |
|
|
60s |
|
The first four are now one POST /api/pulse. The payload is demand-driven:
the client sends the union of the sections its currently-mounted consumers need,
so a phone (where the friends rail is display: none) never makes the server run
the follow-graph fan-out, and a tab with only the nav badge mounted pays for one
counter read. The presence heartbeat is the request’s side effect, so simply
having any subscriber keeps the user “online now”.
online-count was deliberately left out. It needs no session, is identical
for every visitor, and ships as a cacheable GET with max-age=30. Folding it
into a per-user POST would have converted a shared, edge-cacheable response
into an uncacheable one — a net loss.
The four replaced endpoints still exist and still work; nothing in the app calls
them. full-rewrite-design-2026-07-18.md:811 carries the acceptance criterion
“/api/notifications/unread-count QPS reduced >90% in prod” — this is that.
lib/pulse.ts keeps the properties the hooks it replaced had earned, and
lib/__tests__/pulse.test.ts pins them: section demand ref-counting, one shared
in-flight request, a mid-flight subscriber not having to wait a full interval for
a newly-demanded section, “absent section ≠ empty section”, and a response that
raced teardown being unable to repopulate the cache.
2–3. Engagement responses waited on notification writes¶
setPostLike had already been optimized (parallel lookups, a 2-query core
transaction, progression enqueued to pg-boss) — but it then awaited
createNotification, which is itself a preference read, an unread-dedupe
findFirst, and an insert-or-update. Three serial round trips in front of the
response, on the site’s highest-frequency write, for a side effect the liker
never observes. Same shape on unlike, follow, and unfollow.
The comment path was worse: a parent-author lookup, up to two notification chains, and mention resolution — all serial, all in front of the response.
All of these are now deferred. They were already wrapped in internal
try/catch and swallow their own errors, so voiding them changes no error
semantics. The comment block is deferred as a unit and closes over values already
in scope, so it issues no extra reads.
The follow path additionally awaited three serial progressAchievement upserts
against the followed user’s rows; those are now deferred the same way, next to
the enqueueProgression call that was already voided.
4. Hashtag linking ran three queries per tag inside the post-create transaction¶
linkPostHashtags looped per tag doing upsert + create + update. A
10-hashtag post was 30 serial round trips while holding the post-create
interactive transaction open, pinning a pool connection for the sum of all of
their latencies. Now five statements regardless of tag count.
The set-difference step (which tags are newly linked) is computed rather than assumed: on the create path it is always empty, but the same function runs when a post’s content is re-linked, and double-counting there would permanently skew trending.
unlinkPostHashtags went from 1 + N to 3.
5. Five predicate indexes¶
The FK sweep found 83 relation columns that lead no index. That number is misleading and is the most useful thing in this document, so it gets its own section below. Five had a live read path:
rmhtube_room_member(userId, leftAt)andrmh_music_room_member(userId, leftAt)— the social-presence fan-out asks “which of these online users are in an open room” on a per-viewer poll. Both tables were indexed only as(roomId, userId), so the userId-leading lookup seq-scanned both membership tables on every poll. This was the single worst find of the pass.notification(userId, read, type, entityId)— the unread-dedupefindFirstand the retractiondeleteManymatched on(userId, actorId, type, entityType, entityId, read=false)with only(userId, read, createdAt)available, so both scanned every unread row the recipient holds — on every like, comment, follow and unlike.SongComment(songId, createdAt DESC)— this model carried no index at all, so every song page scanned and sorted the whole table.prediction(creatorId, status)— the per-author pending-submission cap, counted on every market creation.
6. getComputedStyle inside the slice-it draw loop¶
getComputedStyle() flushes pending style and layout. components/game/GameCanvas.tsx
called it once per frame for the background colour and again per visible
LONG note, inside the per-slice loop — several forced style recalculations per
frame, on the one screen in the app where frame timing is the gameplay. Six
theme values are now resolved once and invalidated by a MutationObserver on the
<html> class/style attributes (which is what DarkModeWrapper toggles).
This also fixed a latent visual bug: the per-note read resolved
--slice-hold-trail against document.documentElement, but the --slice-*
palette is scoped to .slice-theme (a wrapper div). The lookup therefore
returned "" and silently fell back to the hard-coded light-mode trail colour in
both themes. Reading from the canvas resolves it correctly.
The sweep found no other getComputedStyle in a frame loop. In particular
lib/liquid-gl/ — the site-wide WebGL layer, the one place where this would hurt
every page — is clean by construction: readSceneStatic() uses
getComputedStyle once per theme change, and the per-frame readLiveInputs()
reads inline element.style, which does not flush.
The 83 unindexed foreign keys, and why only five were indexed¶
This is the finding most likely to be re-discovered and over-corrected later, so the reasoning is recorded here.
83 of 328 relation columns lead no index. Postgres does not auto-index FK columns, so the instinct is to add 83 indexes. Don’t. Almost all 83 are only reachable two ways, and both turn out to be closed:
A hard delete of the parent row. For the ~50
CascadeFKs pointing atUser, that would beDELETE FROM user, which this codebase never does:app/routes/api/account/delete.tsis an erasure — it deletes credentials and scrubs the profile, deliberately leaving the row and its authored content in place (“rather than a hard row delete, which risks foreign-key failures across the ~199-model schema”). No user row is ever deleted, so nouserId-leading cascade scan ever runs.The parent-id direction of a post/comment cascade. That direction is already indexed everywhere it matters —
rmheet_view,rmheet_reaction,rmheet_poll_vote,rmheet_comment_like,rmheet_comment_reaction,rmheet_comment_repostandrmheet_comment_vieweach lead withrmheetId,commentIdoroptionIdin a unique or explicit index.
So the remaining 78 are latent, not live. Each index would cost a btree insert on every write to tables that are among the highest-write in the schema (views, reactions, likes), to protect a delete path that does not exist. They are worth revisiting only if account deletion ever becomes a hard delete, or if a moderation tool grows a “purge all content by user” action — at which point the list should be regenerated rather than trusted from here.
Second pass — the rest of the list¶
Everything the first pass deferred was then worked through. This section records what each item turned out to be, including the two where the honest answer was “the finding was overstated”.
9. Unbounded queries — the “194” was overstated¶
The original scan counted any findMany without a take: key, which missed
shorthand take, and queries bounded by an id: { in: [...] } built from an
already-capped set. Corrected count: 163, and of the eight sites the first
pass named, five were false positives:
Site |
Verdict |
|---|---|
|
already takes a |
|
bounded by |
|
shorthand |
|
shorthand |
|
bounded by |
|
real — capped at 50 |
|
real — capped at 100 |
|
author-bounded ( |
The ranked one is the only one that mattered: anyone can open a challenge against you, so the incoming list grows with other people’s actions and each row hydrated a full user-display include. The rest of the 163 are user-scoped lists (your playlists, your API keys, your saved places) whose size the owner controls.
10. The app tier did not need a virtualizer — it needed a bound¶
ChatPanel’s problem was not that it rendered every row; it was that
room.chat grew for the room’s entire lifetime. The same store already capped
systemMessages at slice(-100) and simply never applied the same treatment to
chat. Both RMHTube and RMHMusic had the identical bug. Capped at 200; older
history stays server-side.
With the array bounded the DOM is bounded, which is what virtualization would have bought — at a fraction of the risk of retrofitting a virtualizer into a realtime chat with variable-height rows, reactions, a mention dropdown and autoscroll.
Two adjacent re-render bugs fell out of reading it: getChatEntries was keyed on
the whole store in a useMemo, and useRmhTubeStore() subscribes to the whole
store — which changes on every SYNC_STATE and clock sync — so the entire
transcript was re-merged and re-sorted several times a minute on updates that had
nothing to do with chat. RMHMusic’s copy had no memo at all. getChatEntries now
takes the two arrays it reads so the memo can be keyed correctly.
The other candidates dissolved on inspection: MemberList is structurally capped
(ABSOLUTE_MAX_MEMBERS = 50), and the library lists are user-scoped.
11. 2D canvas games — measured, and the harness is committed¶
scripts/perf/canvas2d-probe.mjs is the 2D counterpart to the (throwaway) WebGL
harness: it patches CanvasRenderingContext2D.prototype and counts rasterising
ops, non-zero shadowBlur activations, gradient/pattern constructions and
getComputedStyle calls, per frame. Unlike the 3D one it is committed, so this is
repeatable.
Measured at 1280×720, DPR 1, software rasteriser (no GPU — treat fps as relative-only, exactly as the 3D audit warns; the op counts are hardware independent):
Route |
fps |
ops/frame |
shadowBlur-on/frame |
gradients/frame |
getComputedStyle/frame |
|---|---|---|---|---|---|
|
44.7 |
15 |
10 |
0 |
0 |
|
59.9 |
16 |
0 |
0 |
0 |
|
33.6 |
1 |
0 |
0 |
0 |
Two things to read off that table:
slice-it enabled a blurred shadow ~10 times per frame against ~15 rasterising operations — two thirds of everything it drew went through a blur, and that is at rest on a menu, before any notes are on screen.
shadowBluris the most expensive thing on a 2D canvas: the shape is rasterised to a scratch surface, blurred, then composited.getComputedStyle/frameis 0, which independently confirms the fix in item 6 above is working in a real browser.
The fix is lib/render/canvas2d-fx.ts — the 2D counterpart to lib/render/tier.ts,
gating decorative blur on the site’s existing low-end signals (html.perf-lite,
the same class that already disables the glass blur, the aurora parallax and the
liquid layer, plus reduced motion) rather than a new heuristic. Resolved once per
theme/class change, never per frame. Verified with the harness:
|
ops/frame |
shadowBlur-on/frame |
|---|---|---|
default |
15 |
10 |
|
15 |
0 |
Identical geometry, zero blur — so the gate drops only the expensive part.
void-breaker already had a reducedFx flag driven by reduced motion; it now also
honours perf-lite, so it degrades on the same signal as everything else.
Honest caveat: fps did not measurably move at rest (44.8 → 44.5) because 15
ops/frame is not enough work for the blur to dominate on this capture machine. The
removed work is real and the gate is verified to fire; the frame-rate claim needs
a run during actual gameplay on real hardware. Routes behind a menu or a loadout
screen (/void-breaker, /house-always-wins, /synapse-storm, /temple-of-joy)
were not reached — the harness reports no canvas animation observed rather than
summarising a menu, and driving them to gameplay needs per-route click selectors
that are not written yet. lib/vega/Renderer.ts and
components/velum2099/game/ui/Minimap.ts still have un-gated shadowBlur and are
not on a routable path this harness could reach.
12. liquid-gl no longer touches the theme in the frame loop¶
frame() built a signature string every frame — including on the idle path that
then returned without rendering — to detect a theme change. The check now lives in
a style MutationObserver, so the frame loop does nothing at all for it.
The reason it was in the loop is documented in the module: inline style churns on
every pointer move via --light-x/y, so a naive style observer would fire
constantly. The callback therefore compares the three colour variables and only
re-parses when one actually changed — a pointer move fails that test and does
nothing — and MutationObserver batches into a microtask, so a drag costs one
comparison per batch instead of one per frame, and an idle page costs nothing.
Measurement¶
The request-count and query-count effects are read off the code and are exact. The
canvas-2D numbers are measured (see item 11 for the harness, the conditions, and
what the numbers do not establish). The migration was applied to a real
PostgreSQL 16 instance and all five indexes verified present, with
prisma migrate status reporting no drift.
Not measured: the latency effect of items 2–4. The RUM and synthetic plumbing in
performance-slo.md is how to confirm those, and its
checklist still has “forward [rum:metric] logs to a durable metrics backend”
unchecked — until that is done, before/after percentiles for a write-path change
are not obtainable from this repo.
The pulse change is the one item with an effect visible without any metrics backend: request count per idle tab per minute, straight from a browser network panel or an Apache access log.
Re-running the canvas probe¶
pnpm dev # or any server on BASE_URL
node scripts/perf/canvas2d-probe.mjs --all --seconds=12
node scripts/perf/canvas2d-probe.mjs --route=/slice-it --reduced
CHROME_PATH overrides the browser binary; BASE_URL defaults to
http://localhost:7005.