Optimization Ideas — 2026-08-05¶
65 new optimizations for rmhstudios.com, none of which are already implemented as of this document’s date. Each one was checked against the code before being written down; the “Evidence” line of every entry names the file that proves the gap.
This is a catalogue, not a roadmap. Nothing here is scheduled, and the
numbering is stable so an agent can be pointed at OPT-31 and know exactly what
it means without re-deriving anything.
How to use this document (read this first if you are an LLM)¶
Every idea below uses the same eight-field shape. Read the fields, not the prose:
Field |
Meaning |
|---|---|
Category |
Which subsystem it touches. Determines which |
Impact |
|
Effort |
|
Risk |
|
Evidence |
The file (and line, where stable) that shows the optimization is currently missing. |
Prior art |
Which other sites/platforms already do this. This is the “things others have that we don’t” column. |
Implementation |
A concrete diff sketch or new file. Adapt paths; do not paste blindly. |
Verify |
The command or measurement that proves it worked. An idea without a passing Verify is not done. |
Rules that override anything written here¶
Everything in the root
CLAUDE.mdstill applies — design tokens,defineHandler,t()for user-facing strings, no edits torouteTree.gen.ts, no new lint warnings.Measure before and after. Every prior performance pass in
docs/states a measured number. Keep that standard; a change with no number is a guess.If an idea conflicts with a finding in
performance-audit-2026-08-04.md,-08-01,-07-30or-07-17, the audit wins — those are measured, this is proposed.Do not re-open anything in the “Already ruled out” appendix at the bottom.
What is deliberately NOT here¶
Anything already shipped: intent prefetching with a 50 ms delay and a 30 s
preload stale time (app/router.tsx), server-seeded loaders, the L1+L2 cache
with pub/sub invalidation (lib/cached.server.ts), the anonymous-HTML edge
cache plugin (server/nitro/anon-html-cache.ts), window virtualization of the
feed (components/feed/FeedList.tsx), content-visibility on feed cards,
view transitions, the Inter Latin-subset preload, gzip pre-compression of
static output, the R2 CDN (lib/storage/asset.ts), the service worker
(public/sw.js), Web Push (lib/push/send.server.ts), the sitemap index,
connectionStateRecovery on all three socket hubs, and the entry-chunk
splitting from the 08-04 audit.
Index¶
ID |
Idea |
Category |
Impact |
Effort |
Risk |
|---|---|---|---|---|---|
01 |
Bundle-size budget gate in CI |
Build/CI |
L |
M |
low |
02 |
Entry-chunk composition guard (static-import tripwire) |
Build/CI |
L |
M |
low |
03 |
|
JS delivery |
M |
M |
low |
04 |
Speculation Rules — document prefetch |
Navigation |
L |
S |
low |
05 |
Speculation Rules — |
Navigation |
XL |
M |
medium |
06 |
Swap client-side |
JS delivery |
M |
M |
low |
07 |
React Compiler (auto-memoization) |
Runtime |
L |
L |
medium |
08 |
Out-of-order streaming SSR with Suspense boundaries |
SSR |
XL |
L |
medium |
09 |
Visibility-gated hydration for below-fold islands |
Runtime |
L |
L |
medium |
10 |
Per-icon |
JS delivery |
M |
S |
low |
11 |
Split |
CSS |
XL |
L |
medium |
12 |
Inline critical CSS, defer the rest |
CSS |
L |
M |
medium |
13 |
Dead-CSS sweep with coverage instrumentation |
CSS |
M |
M |
low |
14 |
Extend |
CSS |
M |
S |
low |
15 |
|
CSS |
M |
S |
low |
16 |
|
CSS/a11y |
M |
S |
low |
17 |
Fallback-font metric overrides (kill swap CLS) |
Fonts |
M |
S |
low |
18 |
Self-host + subset the 12 Google display families |
Fonts |
L |
M |
low |
19 |
|
Fonts |
S |
S |
low |
20 |
Glyph-subset Inter to the shipped character set |
Fonts |
M |
M |
medium |
21 |
|
Images |
L |
M |
low |
22 |
AVIF in the image pipeline |
Images |
L |
M |
low |
23 |
ThumbHash placeholders stored in the DB |
Images |
M |
L |
low |
24 |
Build-time responsive variants for |
Images |
L |
L |
low |
25 |
Cloudflare Image Resizing in front of R2 |
Images |
L |
M |
medium |
26 |
KTX2/Basis texture compression for 3D games |
Media |
L |
L |
medium |
27 |
Adaptive-bitrate delivery + |
Media |
L |
XL |
medium |
28 |
Opus transcodes + range requests for RMHMusic |
Media |
M |
L |
low |
29 |
|
Images |
M |
M |
low |
30 |
bfcache eligibility audit + |
Navigation |
L |
S |
low |
31 |
|
Observability |
M |
S |
low |
32 |
103 Early Hints for the document critical path |
Edge |
L |
M |
medium |
33 |
Viewport prefetch for the feed’s first N links (Save-Data aware) |
Navigation |
M |
M |
low |
34 |
|
Runtime/INP |
L |
M |
low |
35 |
INP attribution + LoAF in RUM |
Observability |
L |
S |
low |
36 |
Move markdown/highlight parsing to a Web Worker |
Runtime/INP |
L |
L |
medium |
37 |
|
Runtime/INP |
L |
XL |
medium |
38 |
Virtualize comments, leaderboards and the library grid |
Runtime |
M |
M |
low |
39 |
Passive listeners + |
Runtime/INP |
M |
S |
low |
40 |
IndexedDB read-through cache for the feed |
Offline |
M |
L |
medium |
41 |
|
Caching |
XL |
M |
medium |
42 |
Weak |
Caching |
L |
M |
low |
43 |
Extend anon-HTML edge caching past |
Edge |
XL |
M |
medium |
44 |
Cloudflare Tiered Cache + Cache Reserve |
Edge |
M |
S |
low |
45 |
Compression dictionaries for versioned JS |
Edge |
L |
L |
medium |
46 |
Content-hash |
Edge |
M |
M |
low |
47 |
Negative caching in |
Caching |
M |
S |
low |
48 |
Redis pipelining for multi-key cache reads |
Caching |
M |
M |
low |
49 |
|
Observability |
L |
S |
low |
50 |
Prisma over-fetch audit ( |
Database |
L |
L |
low |
51 |
Read-replica routing for read-only queries |
Database |
XL |
L |
high |
52 |
PgBouncer transaction pooling |
Database |
L |
M |
medium |
53 |
Materialized views for leaderboards and ranked |
Database |
L |
L |
medium |
54 |
|
Database |
L |
M |
low |
55 |
Per-request DataLoader batching |
Database |
L |
L |
medium |
56 |
Single-round-trip feed assembly via SQL JSON aggregation |
Database |
L |
L |
medium |
57 |
|
Realtime |
M |
M |
medium |
58 |
Socket.io msgpack parser + deflate threshold tuning |
Realtime |
M |
M |
medium |
59 |
Interest management / delta encoding for game state |
Realtime |
L |
XL |
medium |
60 |
Lighthouse-CI budgets on pull requests |
Build/CI |
L |
M |
low |
61 |
|
SEO |
L |
M |
low |
62 |
IndexNow ping on publish |
SEO |
M |
S |
low |
63 |
OpenSearch description document |
SEO |
S |
S |
low |
64 |
Manifest: |
PWA |
M |
S |
low |
65 |
Background Sync queue for offline writes + Badging API |
PWA |
M |
L |
medium |
A. Build, CI and the critical path¶
The 08-04 audit halved the entry chunk. Nothing stops it growing back. Every idea in this section exists to make that regression impossible rather than merely unlikely.
OPT-01 — Bundle-size budget gate in CI¶
Category: Build/CI · Impact: L · Effort: M · Risk: low
Evidence:
.github/workflows/web-ci.ymlruns typecheck, lint, docs freshness, tests, build and a dependency audit. There is no size assertion in any of them.docs/performance-slo.md§”Candidate bundle budgets” documents budgets that nothing enforces.Prior art: GitHub, Shopify (
size-limit), Sentry (size-limit+ a bot comment on every PR), Next.js core repo.
The 08-04 audit’s numbers (253.6 KB entry, 1028.5 KB critical path raw, 297.6 KB brotli) are the natural budget. Encode them.
Implementation — a script that walks the built client manifest, computes the transitive static import closure of the entry, and fails over budget:
// scripts/check-bundle-budget.ts
import { readFileSync } from 'node:fs';
import { statSync } from 'node:fs';
import { brotliCompressSync } from 'node:zlib';
import { join } from 'node:path';
const OUT = '.output/public';
const manifest = JSON.parse(readFileSync(join(OUT, '.vite/manifest.json'), 'utf8'));
/** Budgets are the 2026-08-04 measured numbers + a 5% headroom band. */
const BUDGETS = {
entryRaw: 266_000, // 253.6 KB measured
criticalPathRaw: 1_080_000, // 1028.5 KB measured
criticalPathBrotli: 312_000, // 297.6 KB measured
};
const entry = Object.values<any>(manifest).find((c) => c.isEntry);
if (!entry) throw new Error('no entry chunk in manifest');
/** Transitive STATIC imports only — `dynamicImports` are per-route and excluded. */
const seen = new Set<string>();
(function walk(chunk: any) {
for (const key of chunk.imports ?? []) {
if (seen.has(key)) continue;
seen.add(key);
walk(manifest[key]);
}
})(entry);
const files = [entry.file, ...[...seen].map((k) => manifest[k].file)];
const raw = files.reduce((n, f) => n + statSync(join(OUT, f)).size, 0);
const brotli = files.reduce((n, f) => n + brotliCompressSync(readFileSync(join(OUT, f))).length, 0);
const entryRaw = statSync(join(OUT, entry.file)).size;
const rows = [
['entry, raw', entryRaw, BUDGETS.entryRaw],
['critical path, raw', raw, BUDGETS.criticalPathRaw],
['critical path, brotli', brotli, BUDGETS.criticalPathBrotli],
] as const;
let failed = false;
for (const [label, actual, budget] of rows) {
const pct = ((actual / budget - 1) * 100).toFixed(1);
const ok = actual <= budget;
if (!ok) failed = true;
console.log(
`${ok ? 'ok ' : 'FAIL'} ${label.padEnd(24)} ${(actual / 1024).toFixed(1).padStart(8)} KB` +
` / ${(budget / 1024).toFixed(1)} KB budget (${pct > '0' ? '+' : ''}${pct}%)`,
);
}
console.log(`chunks on the critical path: ${files.length}`);
if (failed) process.exit(1);
Wire it into the existing build job so it costs no extra pnpm build:
# .github/workflows/web-ci.yml — inside the job that already runs the build
- name: Bundle budget
run: pnpm exec tsx scripts/check-bundle-budget.ts
Gotcha: budgets that only ever move up are theatre. Pair this with a rule in
CONTRIBUTING.md: raising a budget requires a line in the PR body saying which
user-visible feature bought the bytes.
Verify: pnpm build && pnpm exec tsx scripts/check-bundle-budget.ts prints
three ok rows on main, and fails if you temporarily re-add a static
import '@discord/embedded-app-sdk' to __root.tsx.
OPT-02 — Entry-chunk composition guard (static-import tripwire)¶
Category: Build/CI · Impact: L · Effort: M · Risk: low
Evidence: The 08-04 audit’s root cause —
routeTree.gen.tsstatically imports all 739 route modules, so anything a route module touches at module scope lands in the shared entry — is documented in a comment invite.config.tsand nowhere enforced.Prior art: Vercel’s
next-bundle-analysisaction; Airbnb’s “import allowlist” checks.
OPT-01 catches the symptom (bytes). This catches the cause (a package that must never be in the entry), and gives a far better error message.
Implementation — build once with sourcemaps in CI and attribute chunk bytes back to their source, exactly as the 08-04 audit did by hand:
// scripts/check-entry-composition.ts (run after `vite build --sourcemap`)
/**
* Packages that must NEVER appear in the entry chunk's static closure.
* Each entry names WHY, so a future failure is self-explaining. Adding a name
* here is cheap; removing one requires a measurement.
*/
const FORBIDDEN = {
'@discord/embedded-app-sdk': 'Discord Activity only — /discord/* loads it itself',
three: 'route-only 3D — must stay behind a lazy() boundary',
'pixi.js': 'route-only 2D renderer',
tone: 'route-only audio engine',
'twemoji-parser': 'one minigame history view',
'@twemoji/api': 'post-hydration only',
'web-vitals': 'dynamically imported by lib/rum.ts on purpose',
zod: 'validators belong in *-schema.ts split points, not the shell',
'emoji-picker-react': 'composer-only',
'maplibre-gl': 'map routes only',
};
Walk the entry’s static closure (same traversal as OPT-01), parse each chunk’s
.map file, and fail naming both the forbidden package and the importing
source file, so the fix is obvious:
FAIL entry closure contains `three` (route-only 3D — must stay behind a lazy() boundary)
reached from: components/games/void-breaker/Scene.tsx
← app/routes/void-breaker.tsx (static import — should be lazy())
Gotcha: run this on the closure of static imports only. A package legitimately present in an async chunk is not a failure; the whole point of the 08-04 split was that async chunks are fine.
Verify: the script passes on main; converting one lazy() route back to a
static import makes it fail with the route named.
OPT-03 — modulepreload the next route’s chunk on intent¶
RESOLVED 2026-08-05 — already satisfied; do not implement. The evidence line below is accurate as a grep and wrong as a conclusion. TanStack Router already fetches the route chunk on intent: the compiled entry lazy-loads all 267 route modules via
lazyRouteComponent, which sets.preloadon the component;runLoadercallsloadRouteChunkon the client; andpreloadRoute— what a<Link>fires on hover — reaches the sameloadMatchespath. Vite’s__vitePreloadthen emits themodulepreloadlinks for that chunk and its dep chain. A hand-written version would be a duplicate fetch path plus a staleable build-time map. The 120 ms delay suggested below is also wrong for the same reason: the data prefetch and the chunk fetch are one call, so raising it would delay the data prefetch to buy nothing. Verified in@tanstack/react-routerlazyRouteComponent.js:47and@tanstack/router-core@1.171.15load-matches.js:357.
Category: JS delivery · Impact: M · Effort: M · Risk: low
Evidence:
app/router.tsxsetsdefaultPreload: 'intent'with a 50 ms delay — that preloads route loader data. There is no<link rel="modulepreload">for the route’s JS chunk, so on a real navigation the browser still discovers the chunk only when the router imports it.Prior art: Next.js App Router preloads both the RSC payload and the JS chunk on hover; Remix/React Router
<PrefetchPageLinks>emitsmodulepreload.
The data is warm and the code is cold — the navigation still waits a round trip.
Implementation — a small hook that maps a route path to its chunk via the Vite manifest, plus an injection point on the same intent signal the router uses:
// lib/route-modulepreload.ts
/**
* Emit <link rel="modulepreload"> for a route's JS chunk on the same hover/focus
* intent that TanStack Router uses to warm loader data. Data without code still
* pays a round trip at click time; this closes it.
*
* Deduped per href for the page's lifetime — a modulepreload is a no-op once the
* module is in the module map, but the DOM nodes are not free.
*/
const preloaded = new Set<string>();
export function modulepreload(chunkUrl: string): void {
if (typeof document === 'undefined' || preloaded.has(chunkUrl)) return;
preloaded.add(chunkUrl);
const link = document.createElement('link');
link.rel = 'modulepreload';
link.href = chunkUrl;
link.crossOrigin = 'anonymous';
document.head.appendChild(link);
}
Route → chunk resolution comes from a build-time map (a tiny Vite plugin that
emits routePath → chunk file from the client manifest), so nothing is guessed
at runtime. Call modulepreload() from the same onPointerEnter/onFocus
handler that ViewTransitionLink already owns
(components/ui/ViewTransitionLink.tsx), gated behind the router’s 50 ms
deliberateness delay so it inherits the existing bandwidth policy.
Gotcha: do not preload on mousemove across a list. The 50 ms gate exists
because brushing past links on a slow connection burns bandwidth — a
modulepreload is far more expensive than a data prefetch, so if anything it
deserves a longer delay (try 120 ms) than the data path.
Verify: DevTools → Network, hover a games-index card for 200 ms; the route
chunk appears with initiator modulepreload before the click. Compare the
click→FCP delta over 10 navigations, hover-first vs click-cold.
OPT-04 — Speculation Rules: document prefetch¶
Category: Navigation · Impact: L · Effort: S · Risk: low
Evidence: No
speculationrulesscript anywhere (grep -r speculationrules app/ components/ lib/is empty). All prefetching is JS-driven through the router.Prior art: Cloudflare’s Speed Brain, WordPress core (since 6.8, ships speculation rules by default), Shopify storefronts, Chrome’s own docs as the canonical example.
The Speculation Rules API lets the browser prefetch whole documents on its own heuristics, off the main thread, with automatic bandwidth/battery/Data-Saver backoff that hand-written JS cannot match.
Implementation — add to the scripts array in __root.tsx’s head():
// app/routes/__root.tsx — inside head()
/**
* Browser-driven document prefetching. `eagerness: "moderate"` = on hover, which
* matches the router's own intent policy; the browser additionally backs off on
* Save-Data, low battery and constrained connections, which our JS path does not.
*
* `where.not` is the important half: never speculate a URL that MUTATES or that
* costs money to render. /api/ is excluded wholesale, and so is every path whose
* GET has a side effect (see docs/optimization-ideas-2026-08-05.md OPT-04).
*/
const speculationRules = JSON.stringify({
prefetch: [
{
source: 'document',
where: {
and: [
{ href_matches: '/*' },
{ not: { href_matches: '/api/*' } },
{ not: { href_matches: '/login*' } },
{ not: { href_matches: '/logout*' } },
{ not: { href_matches: '/checkout*' } },
{ not: { href_matches: '/admin/*' } },
{ not: { selector_matches: '[data-no-speculate]' } },
{ not: { selector_matches: '[rel~="nofollow"]' } },
],
},
eagerness: 'moderate',
},
],
});
rendered as { type: 'speculationrules', children: speculationRules } in the
route’s scripts.
Gotchas:
CSP.
script-srcindeploy/apache/rmhstudios.confalready allows'unsafe-inline', so a<script type="speculationrules">is permitted today. If CSP is ever tightened to nonces (it should be — see OPT-49’s neighbourhood), this script needs the nonce too.Prefetched documents arrive without the user’s interaction, so any route whose GET writes (view counters, “mark as read”, one-time claim links) must be in the
notlist. Auditapp/routes/api/**for GET handlers that write before shipping this —app/routes/api/rmhladder/prep.tsalready documents itself as non-prefetchable, which is exactly the class to look for.Prefetch sends cookies for same-site documents, so an authenticated prefetch of a personalized page is correct but consumes origin CPU. Keep
eagerness: 'moderate'(hover), not'eager'.
Verify: Chrome DevTools → Application → Speculative loads. Hover a link; the
status goes Ready. Navigation should show a near-zero TTFB in the Network panel.
OPT-05 — Speculation Rules: prerender the highest-confidence links¶
Category: Navigation · Impact: XL · Effort: M · Risk: medium
Evidence: Same as OPT-04 — no speculation rules at all.
Prior art: Cloudflare Speed Brain, Google Search result prerendering, Amazon’s next-page prerender.
Prefetch removes the network. Prerender removes the render — the next page is fully constructed, hydrated and painted in a hidden tab-like context, and the navigation is an activation, typically < 50 ms end to end. On a site whose per-page cost is dominated by hydration of a large shell, this is the single biggest navigation win available.
Restrict it to links the user is overwhelmingly likely to take:
{
"prerender": [
{
"source": "list",
// Populated at render time from the CURRENT page's context:
// - the "next post"/"next chapter" link in the library reader
// - the primary CTA on a game card the user is already hovering
// - page 2 of a paginated list the user has scrolled to the bottom of
"urls": ["/library/the-next-chapter"],
"eagerness": "moderate",
},
],
}
Gotchas — these are why the risk is medium:
A prerendered page runs its effects. Analytics, RUM beacons, socket connections and
POSTs in mount effects all fire for a page the user may never visit. Guard withdocument.prerenderingand theprerenderingchangeevent:// lib/prerender.ts /** Run `fn` now, or defer it until this prerendered document is activated. */ export function whenActivated(fn: () => void): void { if (typeof document === 'undefined') return; if (!(document as Document & { prerendering?: boolean }).prerendering) return void fn(); document.addEventListener('prerenderingchange', () => fn(), { once: true }); }
Then wrap the beacon path in
lib/rum.tsand any socketconnect()inwhenActivated(...), or every prerender inflates traffic and corrupts RUM.Only ever prerender one URL at a time; Chrome caps concurrent prerenders and evicts aggressively, and each one costs a full renderer.
Never prerender a page with a side-effecting loader.
Verify: DevTools → Application → Speculative loads shows prerender →
Ready; the follow-up navigation reports activation-start in the navigation
timing and an LCP measured from activation, not from request start. Confirm RUM
sample counts do not rise after rollout — if they do, the whenActivated
guard is missing somewhere.
OPT-06 — Swap client-side zod for zod/mini¶
Category: JS delivery · Impact: M · Effort: M · Risk: low
Evidence: The 08-04 audit found 69.7 KB of zod one hop off the entry and fixed it by moving schemas to
*-schema.tssplit points. zod is still shipped to the client wherever a form validates locally.Prior art: the valibot/
zod/minimigration wave across 2025–26 front ends; Astro and Nuxt both ship tree-shakeable validators by default now.
The functional zod/mini API is tree-shakeable in a way the method-chaining core
API structurally is not — a schema that uses six validators pulls six functions
instead of the whole ZodString prototype.
// Before — the chained API retains the full class surface
import { z } from 'zod';
export const profileSchema = z.object({
handle: z
.string()
.min(3)
.max(20)
.regex(/^[a-z0-9_]+$/),
bio: z.string().max(300).optional(),
});
// After — only the used checks are retained
import * as z from 'zod/mini';
export const profileSchema = z.object({
handle: z.string().check(z.minLength(3), z.maxLength(20), z.regex(/^[a-z0-9_]+$/)),
bio: z.optional(z.string().check(z.maxLength(300))),
});
Do this on the client only. Server-side schemas in defineHandler are behind
the SSR boundary where bytes do not matter and the chained API is more readable —
churning them costs review time for zero user benefit.
Gotcha: the two APIs share a runtime, so a file importing both doubles nothing, but mixing them in one schema is a type error. Convert whole files.
Verify: run OPT-01’s script before/after and diff the “critical path, raw”
row; separately confirm pnpm exec vitest run still passes every schema test.
OPT-07 — React Compiler¶
Category: Runtime · Impact: L · Effort: L · Risk: medium
Evidence:
package.jsonhas nobabel-plugin-react-compiler;vite.config.tsconfigures@vitejs/plugin-reactwith nobabeloption.Prior art: Meta ships it across facebook.com and instagram.com; Vercel and Shopify Hydrogen enable it by default in new apps.
React 19 is already the stack of record, so the compiler’s requirements are met.
It memoizes automatically and, on a component tree this large, generally removes
more re-renders than any hand-written useMemo campaign would.
// vite.config.ts
react({
babel: {
plugins: [
[
'babel-plugin-react-compiler',
{
// Start in "annotation" mode: only files with a "use memo" directive
// are compiled. Widen to the whole tree once the rollout files are green.
compilationMode: 'annotation',
target: '19',
},
],
],
},
}),
Rollout order (highest re-render pressure first, each verified before the next):
components/feed/** → components/shared/** (the app tier) → components/ui/**.
Games are last and may be excluded permanently: their render loops are
requestAnimationFrame-driven and gain nothing.
Gotchas:
The compiler bails on components that mutate props or read refs during render. Run
npx react-compiler-healthcheckfirst — it prints exactly which components are incompatible and why, and the count is the real effort estimate.Build time goes up (a Babel pass over every
.tsx). Measure againstdocs/opti/build-audit.md’s numbers before accepting.It changes when effects observe values. Anything relying on a stale closure deliberately (there are a few in game code) will surface here.
Verify: React DevTools Profiler on / — record a scroll + a like, compare
committed-component counts before/after. Then INP p75 in RUM over a week.
OPT-08 — Out-of-order streaming SSR with Suspense boundaries¶
Category: SSR · Impact: XL · Effort: L · Risk: medium
Evidence:
app/routes/__root.tsxawaits the session (bounded at 800 ms bySESSION_LOADER_TIMEOUT_MS) before the document renders; route loaders are documented as “server-seeded” and awaited. The shell cannot flush before the slowest loader on the page.Prior art: Next.js App Router (
loading.tsx+ streaming), Remixdefer, Shopify Hydrogen, Airbnb’s early streaming work.
TTFB is currently gated by the slowest piece of data on the page. With streaming it is gated by the shell — the header, nav and skeletons paint while the feed is still being assembled.
// A route loader today: everything is awaited before anything renders.
export const Route = createFileRoute('/_site/explore')({
loader: async () => ({
trending: await getTrending(), // fast (cached)
recommended: await getRecommended(), // slow (personalized, uncached)
}),
});
// Streamed: return the slow one as a PROMISE and let the boundary resolve it.
export const Route = createFileRoute('/_site/explore')({
loader: async () => ({
trending: await getTrending(),
recommended: getRecommended(), // NOT awaited — streamed to the client
}),
});
function Explore() {
const { trending, recommended } = Route.useLoaderData();
return (
<PageLayout>
<TrendingRail items={trending} />
<Suspense fallback={<RailSkeleton />}>
<Await promise={recommended}>{(items) => <RecommendedRail items={items} />}</Await>
</Suspense>
</PageLayout>
);
}
Gotchas:
CLS. A streamed-in section that changes height re-lays-out the page. Every
fallbackmust be a layout-matched skeleton of the same height —components/ui/skeletons/already holds the right primitives, andapp/routes/_site/communities.tsxalready documents this pattern for cold navigations.Streaming and the anonymous-HTML edge cache (
server/nitro/anon-html-cache.ts) interact: a chunked response is still cacheable, buts-maxagenow covers a document whose late chunks were personalized. Keep the personalized parts out of the cacheable path set.head()content must be emitted in the first flush. Anything that computes meta tags from a slow loader blocks the shell and defeats the exercise.
Verify: curl -sN https://rmhstudios.com/explore | ts — the first bytes should
arrive at shell-render time, with later chunks trailing. In RUM, TTFB p75 falls
and FCP p75 falls with it; LCP should not regress (if it does, the LCP element is
inside a Suspense boundary and should be hoisted out).
OPT-09 — Visibility-gated hydration for below-fold islands¶
Category: Runtime · Impact: L · Effort: L · Risk: medium
Evidence: Hydration is whole-tree;
content-visibility: autoinapp/globals.cssskips rendering of off-screen feed cards but React still hydrates every one of them.Prior art: Astro islands (
client:visible), Qwik resumability, Marko, Next.js’snext/dynamic+ssr:falsepatterns.
The site already has the hard half: server-rendered HTML that is correct without JS. What is missing is deferring the attachment of interactivity.
// components/ui/HydrateOnVisible.tsx
/**
* Server-render children normally, but defer client hydration until the wrapper
* scrolls near the viewport. The SSR HTML is the source of truth until then, so
* a user who never scrolls never pays for the widget's JS or its effects.
*
* Only safe for widgets whose SSR output is COMPLETE and non-interactive until
* hydrated — a sidebar rail, a recommendation shelf, a comment thread. Never wrap
* anything the user can hit above the fold.
*/
export function HydrateOnVisible({
children,
fallbackMinHeight,
}: {
children: ReactNode;
fallbackMinHeight: number;
}) {
const ref = useRef<HTMLDivElement>(null);
const [hydrate, setHydrate] = useState(false);
useEffect(() => {
if (hydrate || !ref.current) return;
const io = new IntersectionObserver(
([e]) => e.isIntersecting && setHydrate(true),
{ rootMargin: '400px' }, // start a screen early so it is never visibly late
);
io.observe(ref.current);
return () => io.disconnect();
}, [hydrate]);
// `min-height` keeps the box the same size before and after, so promoting a
// placeholder to a live component never shifts layout.
return (
<div ref={ref} style={{ minHeight: fallbackMinHeight }}>
{hydrate ? children : null}
</div>
);
}
Gotcha: React 19 hydration is all-or-nothing per root. The honest version of
this needs either (a) separate roots for the islands, or (b) rendering the
children as static markup via dangerouslySetInnerHTML from the SSR pass and
swapping in the live tree on intersection. (b) is simpler but duplicates markup;
prototype on one shelf before committing.
Verify: Performance panel, cold load of / with 6× CPU throttling — total
scripting time during hydration should drop measurably, and INP on the first
interaction should not regress.
OPT-10 — Per-icon lucide-react import lint rule¶
Category: JS delivery · Impact: M · Effort: S · Risk: low
Evidence:
lucide-reactis inssrOnlyExternalsinvite.config.ts(so it is bundled for the client) and is used across the whole component tree. Nothing enforces how it is imported.Prior art: MUI’s
no-restricted-importsconvention for barrel files; theeslint-plugin-importno-namespacerule in most large design systems.
Barrel imports from icon packages are the classic silent bloat: one
import { Heart } from 'lucide-react' in a shell module can retain the barrel,
and rolldown’s ability to shake it depends on side-effect annotations holding.
Add to eslint.config.mjs:
{
rules: {
'no-restricted-syntax': [
'warn',
{
// Namespace imports of an icon barrel defeat tree-shaking outright.
selector: "ImportNamespaceSpecifier[parent.source.value='lucide-react']",
message:
"Import icons by name — `import { Heart } from 'lucide-react'` — never `import * as`.",
},
],
},
}
…and, more valuably, add an assertion to the OPT-02 composition guard: the entry
chunk should contain at most the icons used by the shell (roughly a dozen). If
it contains hundreds, the barrel is being retained and the fix is
lucide-react/icons/heart deep imports in shell modules specifically.
Verify: count icon symbols in the entry chunk before/after
(grep -o 'createLucideIcon' .output/public/assets/entry-*.js | wc -l).
B. CSS delivery¶
app/globals.css is 240,850 bytes of source, and __root.tsx’s own
font-preload comment puts the built sheet at 433 KB. It is a single
render-blocking stylesheet linked from __root.tsx, and every page on the site —
including a full-screen game that uses the --app-* contract and almost none of
the --site-* one — downloads, parses and style-recalculates all of it.
That makes it the largest single render-blocking resource on the critical path:
larger, built, than the entire 253.6 KB entry chunk the 08-04 audit worked to
halve. Re-measure the built size (ls -l .output/public/assets/*.css) before
acting on any number in this section.
OPT-11 — Split globals.css into a site-shell sheet and an app-shell sheet¶
Category: CSS · Impact: XL · Effort: L · Risk: medium
Evidence:
app/routes/__root.tsxlinks: [... { rel: 'stylesheet', href: appCss }]— one sheet, unconditionally, for all 739 routes.components/shared/app-theme.cssalready establishes that the full-screen app tier is a separate token contract (--app-*).Prior art: every large app with more than one shell — Figma, Linear, Notion — ships route-scoped CSS. Next.js and Astro both do this automatically per route.
The architecture already draws the line: app/routes/_site/** gets the radial
shell, top-level routes are full-screen. The CSS does not respect that line.
Implementation
Split by contract, not by feature:
app/base.css— reset,@themetoken declarations, typography, focus rings, the.glass-*elevation classes. Loaded everywhere.app/site.css— everything that only ever matches inside_site/**: the radial shell, sidebar, feed cards,PageLayoutchrome.app/app-shell.css— the--app-*tier,AppShell, full-screen chrome.
__root.tsxlinksbase.cssonly. The_sitelayout route linkssite.css;components/shared/AppShell.tsx’s route parents linkapp-shell.css:// app/routes/_site.tsx import siteCss from '@/app/site.css?url'; export const Route = createFileRoute('/_site')({ head: () => ({ links: [{ rel: 'stylesheet', href: siteCss }] }), });
Keep one
@themeblock. Tailwind v4 tokens must not be declared twice or the two sheets fight;base.cssowns them and the others only consume.
Gotchas:
lib/__tests__/design-consistency.test.tsscans for hand-rolled styling. Make sure the split does not move a class out of the file the test globs — update the glob in the same commit.Cascade order matters. A route-level
<link>is appended after the root one, which is what you want (site rules win over base), but check@layerordering explicitly rather than relying on document order.A cold navigation from a game to a
_sitepage now needs a stylesheet it does not have. That is a render-blocking fetch mid-navigation — pair this with OPT-03 (modulepreloadsibling:<link rel="prefetch" as="style">on intent).
Verify: DevTools → Coverage on /void-breaker: unused CSS bytes should drop
by the size of site.css. Compare FCP on a full-screen route before/after.
OPT-12 — Inline critical CSS, defer the rest¶
Category: CSS · Impact: L · Effort: M · Risk: medium
Evidence:
__root.tsxlinks the full stylesheet render-blocking. There is no inline critical block. The font-preload comment in that file explicitly notes the sheet is large enough to delay font discovery.Prior art:
critters/beasties(Angular CLI ships it by default), Next.jsexperimental.inlineCss, Nuxt’sinlineSSRStyles, WordPress core.
Even after OPT-11, base.css blocks first paint. Inlining the ~8–14 KB that the
above-the-fold shell actually uses and loading the remainder asynchronously turns
one render-blocking round trip into zero.
// A Nitro/Vite plugin sketch — run at build, not per-request.
// Uses `beasties` (the maintained fork of critters) against the SSR'd HTML of a
// few representative routes, then emits the union as `critical.css`.
import Beasties from 'beasties';
const beasties = new Beasties({
path: '.output/public',
// Keep the extracted set small and stable: the shell, not the page.
pruneSource: false,
reduceInlineStyles: false,
fonts: false, // fonts are already preloaded explicitly in __root.tsx
});
Then in head():
// Inline the shell's critical rules; load the full sheet without blocking paint.
// `media="print"` + onload flip is the widely-deployed no-JS-required trick;
// the <noscript> fallback keeps it correct when scripting is off.
scripts: [{ children: criticalCss, type: 'text/css' }], // emitted as <style>
links: [
{ rel: 'stylesheet', href: appCss, media: 'print', onLoad: "this.media='all'" },
],
Gotchas:
Critical CSS extracted from one route is wrong for another. Extract the union of
/, a game route and an app route, and treat it as shell-only — never let it grow past ~14 KB (one TCP congestion window’s worth of the document).It goes stale silently. Regenerate it in the build, never by hand, or the first paint slowly drifts away from the real styles.
Theme flash: the inline theme script in
__root.tsxsets classes on<html>before hydration. The critical block must include the token declarations for every theme, or a non-default theme paints wrong for one frame.
Verify: Lighthouse “Eliminate render-blocking resources” should show 0 ms savings available; FCP p75 in RUM.
OPT-13 — Dead-CSS sweep with coverage instrumentation¶
Category: CSS · Impact: M · Effort: M · Risk: low
Evidence: 240 KB of hand-written CSS accumulated across a full rewrite, a liquid-glass v1 redesign and a v2 optics pass (
docs/plans/2026-07-14-*,2026-07-21-*). Retired features leave rules behind; nothing sweeps them.Prior art: every mature product runs a periodic coverage sweep; Chrome’s Coverage panel and
puppeteer’sCSSCoverageAPI exist for exactly this.
Automate it rather than eyeballing:
// scripts/css-coverage.ts — run against a local production build.
import { chromium } from 'playwright';
const ROUTES = ['/', '/games', '/blog', '/library', '/rmhtube', '/void-breaker', '/settings'];
const browser = await chromium.launch();
const used = new Set<string>();
for (const route of ROUTES) {
const page = await browser.newPage();
await page.coverage.startCSSCoverage();
await page.goto(`http://localhost:7005${route}`, { waitUntil: 'networkidle' });
// Exercise the page a little — hover, open a menu, scroll — or "unused" will
// include every hover/focus/open state in the sheet and the report is garbage.
await page.mouse.wheel(0, 4000);
for (const entry of await page.coverage.stopCSSCoverage()) {
for (const r of entry.ranges) used.add(`${entry.url}:${r.start}-${r.end}`);
}
await page.close();
}
Gotcha — the reason naive coverage sweeps break sites: coverage only sees
states you triggered. Rules for :hover, [data-state=open], high-contrast,
RTL (ar/ur), reduced-motion, and every non-default theme will read as unused.
Treat the report as a candidate list for human review, never as a delete list,
and run the theme matrix (light, dark, high-contrast) plus one RTL locale.
Verify: each deletion is a separate commit; pnpm exec vitest run (which
includes the UI consistency gate) plus a visual pass on the three themes.
OPT-14 — Extend content-visibility past the feed¶
Category: CSS · Impact: M · Effort: S · Risk: low
Evidence:
app/globals.cssusescontent-visibility: autofor feed cards (with a documentedvisibleoverride during scroll restore). Comment threads, the library grid, leaderboard tables and the games index get nothing.Prior art: Chrome’s own docs use exactly this pattern; Facebook and Reddit apply it to comment trees.
/* Long, uniform lists: skip rendering work for off-screen rows. The
`contain-intrinsic-size` is the ESSENTIAL half — without a size hint the
scrollbar jumps as rows are rendered and un-rendered. Measure a real row and
use that number; `auto <n>px` lets the browser remember the real size once
it has rendered the element once. */
.comment-row {
content-visibility: auto;
contain-intrinsic-size: auto 132px;
}
.library-card {
content-visibility: auto;
contain-intrinsic-size: auto 320px;
}
Gotchas:
Never apply it to an element that can receive focus while off-screen — the browser will force-render it anyway and you have paid for the containment for nothing. Also breaks in-page
Ctrl+Fon some engines for skipped content.Scroll anchoring fights it. The existing feed override (
content-visibility: visibleduring restore) exists for this reason — reuse the same mechanism rather than inventing a second one.
Verify: Performance panel → “Rendering” → record a scroll of a 200-comment
thread. Style + layout time per frame should drop; watch for scrollbar jitter,
which means contain-intrinsic-size is wrong.
OPT-15 — contain: layout paint on repeated card surfaces¶
REJECTED 2026-08-05 — audited and not applied. The entry rests on the design language’s claim that floating UI is all
.glass-overlay(L4) and portalled. That claim does not hold in the code: the RMHark overflow menu and the repost menu are anchoredabsolute top-fullinside the.glass-fillfeed card and are not portalled, socontain: paintclips them outright. The role is also not only repeated cards — it isCard’s default tier, ~150 call sites across 86 files, including a sticky modal footer and an absolutely-placed profile-hero badge. The full audit lives as a comment at the call site inapp/globals.css. If containment is wanted later, it belongs on a surface already clipping viaoverflow: hidden, where it is a visual no-op.
Category: CSS · Impact: M · Effort: S · Risk: low
Evidence: No
contain:declarations inapp/globals.cssoutside thecontent-visibilityblock.Prior art: standard practice in any virtualized list implementation.
Containment tells the engine that a subtree’s layout and paint cannot affect anything outside it, so a change inside one card cannot dirty the whole document. On a feed where a like-count animates, that is the difference between a document-wide layout pass and a card-sized one.
/* The `.glass-fill` role is, by the design language's own definition, a REPEATED
card. That makes it exactly the set that benefits from containment: its
contents can never influence siblings. `style` is deliberately omitted —
counter/quotes containment has bitten more sites than it has helped. */
.glass-fill {
contain: layout paint;
}
Gotcha: contain: paint creates a containing block for fixed/absolute
descendants and clips overflow. Anything that deliberately overflows its card —
a hover card, a dropdown, a tooltip anchored inside a feed post — will be
clipped. Those live at .glass-overlay (L4) per the design language and are
portalled out, so they should be unaffected; verify each one before shipping.
Verify: Performance panel — trigger a like on the 40th feed card and compare “Layout” tree size in the trace before/after.
OPT-16 — prefers-reduced-transparency degradation tier¶
Category: CSS / a11y · Impact: M · Effort: S · Risk: low
Evidence: The design language documents degradation tiers that switch off glass materials, and
useReducedMotionis respected throughout — butgrep -r 'prefers-reduced-transparency' app/is empty.Prior art: Apple’s own web properties honour it; it maps directly to the OS “Reduce Transparency” setting that iOS/macOS users with vestibular or visual sensitivities already have on.
Backdrop filters are the single most expensive thing the design language asks the compositor to do (the 08-01 audit measured a full-page blur being re-rasterised every frame). Users who have already told their OS they do not want it should get the cheap path — this is both an accessibility win and a performance win on exactly the devices that need it.
/* Users with "Reduce Transparency" enabled get the opaque tier of the SAME
design language — not a different look, the existing degraded tier. This is
the identical switch the low-power tier already flips, so no new visual
design is needed. */
@media (prefers-reduced-transparency: reduce) {
.glass-fill,
.glass-pane,
.glass-chrome,
.glass-overlay,
.glass-inset {
backdrop-filter: none;
-webkit-backdrop-filter: none;
background-color: var(--site-surface-solid);
}
}
Gotcha: --site-surface-solid must exist in every theme, including
high-contrast. If the token is missing anywhere the surface goes transparent
and text lands on the aurora background — check all themes before shipping.
Verify: macOS System Settings → Accessibility → Display → Reduce transparency, then reload. Compare a Performance trace of a scroll with and without.
C. Fonts¶
OPT-17 — Fallback-font metric overrides¶
Category: Fonts · Impact: M · Effort: S · Risk: low
Evidence:
app/globals.cssusesfont-display: swapfor Inter. There are nosize-adjust,ascent-override,descent-overrideorline-gap-overridedeclarations anywhere in the file.Prior art: Next.js
next/fontgenerates these automatically for every font it handles; Google’s own web.dev guidance; Bootstrap 5.3.
swap guarantees text is visible early — and guarantees a reflow when the real
font lands, because the fallback has different metrics. An @font-face for the
fallback with corrected metrics makes that swap invisible and removes the CLS.
/* A metric-matched local fallback for Inter. The four override values are
computed from the two fonts' OS/2 tables — do not hand-tune them; generate
with `fontkit`/`capsize` and paste the output, then never touch it again.
The numbers below are illustrative: RECOMPUTE for the shipped Inter subset. */
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107.12%;
ascent-override: 90.2%;
descent-override: 22.48%;
line-gap-override: 0%;
}
:root {
/* Insert BEFORE the generic families in the existing token, so the metric-
matched face is what actually renders during the swap window. */
--site-font-sans: 'Inter Variable', 'Inter Fallback', system-ui, sans-serif;
}
Generate the numbers in a script so they are reproducible:
// scripts/gen-font-metrics.ts
import fontkit from 'fontkit';
const real = fontkit.openSync(
'node_modules/@fontsource-variable/inter/files/inter-latin-wght-normal.woff2',
);
const fallback = fontkit.openSync('/System/Library/Fonts/Supplemental/Arial.ttf');
const sizeAdjust =
real.unitsPerEm / real.avgCharWidth / (fallback.unitsPerEm / fallback.avgCharWidth);
// ascent/descent/lineGap overrides = real.<metric> / real.unitsPerEm, adjusted by sizeAdjust
Gotcha: local('Arial') resolves differently per platform. Ship a small
@supports-free cascade — Arial on Windows/macOS, Roboto on Android, and let
system-ui catch the rest — or accept that the override is approximate on Linux.
Verify: WebPageTest filmstrip, or a local CLS measurement with the font request throttled to 3 s: the text should not shift when Inter lands.
OPT-18 — Self-host and subset the 12 Google display families¶
Category: Fonts · Impact: L · Effort: M · Risk: low
Evidence:
app/routes/__root.tsxdeferredFontsScriptinjects one Google Fonts stylesheet requesting twelve families: JetBrains Mono, Playfair Display, Bangers, Bebas Neue, Orbitron, Cinzel, Pacifico, Space Grotesk, Permanent Marker, Caveat, Dancing Script, Patrick Hand — several as full variable ranges (wght@100..800,wght@400..900). Four more routes (rmh-capital.tsx,covid.tsx,adaptive-intelligence.tsx,slice-it.tsx,rmh-farming-sim.tsx) each request additional families from the same host.Prior art: self-hosting is now the default everywhere post-GDPR — Fontsource exists for this, and the repo already uses it for Inter.
Deferring the request to requestIdleCallback was the right first move, but the
cost is still there: a third-party DNS + TLS + two round trips (CSS, then fonts),
a preconnect on the critical path for a resource that is deliberately not on
the critical path, and — because Google’s CSS is UA-sniffed and cache-busted —
no long-lived caching.
Implementation
Add the families via Fontsource, exactly like Inter:
pnpm add @fontsource/bangers @fontsource/bebas-neue @fontsource-variable/orbitron \ @fontsource-variable/playfair-display @fontsource-variable/jetbrains-mono # …
Serve the
.woff2files from R2 through the existingasset()helper (lib/storage/asset.ts), so they inherit the CDN and its cache headers.Declare them in a separate, lazily-linked sheet —
app/display-fonts.css— so they never touchbase.css, and link it from the routes that use them rather than from__root.tsx.Delete
deferredFontsScriptand thefonts.googleapis.com/fonts.gstatic.compreconnects from__root.tsx. Those two preconnect hints cost a DNS + TLS handshake on every page load for a resource most pages never request.
Gotchas:
Subset before shipping.
BangersandPermanent Markerare used for a handful of headings — ship Latin-basic only (pyftsubset --unicodes=U+0000-00FF), which typically cuts each to 15–25 KB.Playfair Displayis--site-font-displayperglobals.css:898, i.e. it is a shell font on some pages. Confirm which routes actually render it before deciding whether it belongs inbase.cssor the lazy sheet.CSP: dropping Google Fonts lets you remove
https://fonts.googleapis.comfromstyle-srcandhttps://fonts.gstatic.comfromfont-srcindeploy/apache/rmhstudios.conf— do it in the same PR, a stale allowance is a standing risk.
Verify: curl -s https://rmhstudios.com/ | grep -c fonts.googleapis → 0.
Network panel on a cold load shows zero third-party font requests. Compare the
number of connections established in the first 2 s.
OPT-19 — font-display: optional for decorative families¶
Category: Fonts · Impact: S · Effort: S · Risk: low
Evidence: every Google Fonts URL in the repo ends
&display=swap.Prior art: web.dev’s font-loading guidance; used by news sites for display faces specifically.
swap is correct for body text — you always want the words. For a decorative
heading face, optional is better: the browser gives it ~100 ms, and if it is not
there, uses the fallback and never swaps. Zero CLS, guaranteed. On a repeat
visit the font is cached and always used.
/* Decorative only. Body text stays `swap` — never make readable text optional. */
@font-face {
font-family: 'Bangers';
src: url('/fonts/bangers-latin.woff2') format('woff2');
font-display: optional;
}
Gotcha: optional means first-time visitors on slow connections may never see
the decorative face. That is a design decision, not a technical one — get it
agreed before shipping, and never apply it to a face that carries meaning
(a logo, a game’s identity type).
Verify: throttle to Slow 3G, hard-reload — the heading should render in the fallback and stay there for that load.
OPT-20 — Glyph-subset Inter to the shipped character set¶
Category: Fonts · Impact: M · Effort: M · Risk: medium
Evidence:
__root.tsxpreloadsinter-latin-wght-normal.woff2— the full Fontsource Latin subset (~47 KB per the file’s own comment), which includes glyphs the UI never renders.Prior art: Google Fonts does this per-request via
text=; Shopify and Wikipedia ship hand-subsetted faces.
The site has an authoritative string corpus: locales/en/*.json plus the
defaultValues in source. Subsetting to the union of characters actually used
typically cuts a Latin variable subset by 30–50%.
# scripts/subset-inter.sh — run in the build, not by hand.
# Collect every character the English UI can render, plus punctuation and digits.
node -e '
const fs=require("fs"),g=require("glob");
const chars=new Set();
for (const f of g.sync("locales/en/*.json"))
for (const c of JSON.stringify(JSON.parse(fs.readFileSync(f,"utf8")))) chars.add(c);
fs.writeFileSync(".fontsubset.txt",[...chars].join(""));
'
pyftsubset node_modules/@fontsource-variable/inter/files/inter-latin-wght-normal.woff2 \
--text-file=.fontsubset.txt \
--flavor=woff2 --layout-features="kern,liga,calt,tnum" \
--output-file=public/fonts/inter-latin-subset.woff2
Gotchas — this is the medium risk:
User-generated content. The feed renders arbitrary text: names, posts, comments from 16 locales. A subset that only covers the UI will show tofu for a user whose display name has a character you dropped. Mitigate by keeping the full Latin subset as a second
@font-facewith aunicode-rangecovering everything the subset omits, so it loads only when such a character appears.Do not subset away
kern/liga— text quality regresses visibly.The other six
unicode-rangesubsets (Cyrillic, Greek, Vietnamese…) must stay exactly as they are; they already load conditionally.
Verify: file size of the preloaded woff2 before/after; then render a page
containing çğıİöşüÅÆØåæøĄĆĘŁŃŚŹŻ and confirm no tofu.
D. Images and media¶
OPT-21 — fetchpriority="high" and a preload for the LCP image¶
Category: Images · Impact: L · Effort: M · Risk: low
Evidence:
grep -rn 'fetchPriority' components/ app/returns nothing.components/ui/OptimizedImage.tsxsetsdecoding="async"but no priority.Prior art: universal — Next.js
<Image priority>, Nuxt Image, Astro’s<Image priority>, and Chrome’s own LCP guidance. This is the single most commonly-cited LCP fix and the site does not do it.
Every image on the page currently competes at the same priority. The browser guesses, and for a hero/cover image it usually guesses “low” until layout proves otherwise — which is exactly the delay LCP measures.
// components/ui/OptimizedImage.tsx
interface OptimizedImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'srcSet'> {
// …existing props…
/**
* Mark this image as the page's LCP candidate. Sets fetchpriority=high,
* forces eager loading and synchronous decode, and (via <PreloadLcpImage>)
* emits a <link rel=preload> so the fetch starts from the HTML rather than
* after layout. AT MOST ONE per page — a second "priority" image halves the
* benefit for the first.
*/
priority?: boolean;
}
// …in the render:
<img
src={url}
srcSet={srcSet}
sizes={sizes}
fetchPriority={priority ? 'high' : undefined}
loading={priority ? 'eager' : 'lazy'}
decoding={priority ? 'sync' : 'async'}
width={width}
height={height}
{...rest}
/>;
Pair it with a real preload from the route’s head(), which is what actually
removes the discovery delay:
// In a route that renders a known hero image (e.g. app/routes/_site/blog/$slug.tsx)
head: ({ loaderData }) => ({
links: loaderData?.coverUrl
? [
{
rel: 'preload',
as: 'image',
href: buildOptimizedUrl(loaderData.coverUrl, 1280, 80, 'avif'),
// imagesrcset/imagesizes make the preload pick the SAME candidate the
// <img> will — without them the browser preloads one URL and the img
// requests another, and you have downloaded the image twice.
imageSrcSet: generateSrcSet(loaderData.coverUrl, 80, 'avif'),
imageSizes: '100vw',
fetchPriority: 'high',
},
]
: [],
}),
Gotchas:
imagesrcsetmust match the<img>’ssrcsetandsizesexactly, or you pay for two images. This is the most common way this optimization backfires.Do not mark more than one image
priority. Audit: the feed’s first post image, the blog cover, the game hero, the library reader’s page image.decoding="sync"on a large image can block the main thread; only for the LCP candidate, never for a list.
Verify: Lighthouse “LCP request discovery” audit should pass. In the Network
panel the LCP image’s priority column reads High and its start time moves to
the first wave. Then LCP p75 in RUM.
OPT-22 — AVIF in the image pipeline¶
Category: Images · Impact: L · Effort: M · Risk: low
Evidence:
components/ui/OptimizedImage.tsxdeclaresformat?: 'webp' | 'avif' | 'jpeg' | 'png'and the comment says the format is “auto-negotiated via Accept header if omitted” — but nothing in the codebase sends avif by default, andpublic/og.webpshows webp is the working format.Prior art: Netflix, Cloudinary, Vercel’s image optimizer (avif first since 2023), Squoosh’s own defaults.
AVIF is typically 25–40% smaller than WebP at matched quality on photographic content, and every browser the site supports has shipped it since 2023.
// Server side (the resizer behind /api/feed/image/, /api/library/cover/, …)
/**
* Negotiate the response format from Accept, preferring the smallest format the
* client actually claims. Order matters: avif first, webp second, then the
* source format. `q=` values in Accept are ignored deliberately — Chrome sends
* avif without a q value and Safari's ordering is not meaningful.
*/
function negotiateFormat(accept: string | null, requested?: string): 'avif' | 'webp' | 'jpeg' {
if (requested === 'avif' || requested === 'webp' || requested === 'jpeg') return requested;
const a = accept ?? '';
if (a.includes('image/avif')) return 'avif';
if (a.includes('image/webp')) return 'webp';
return 'jpeg';
}
and the response must carry:
headers: {
'content-type': `image/${format}`,
// Without this a shared cache will serve an avif to a client that cannot
// decode it. This is not optional when a CDN sits in front.
vary: 'Accept',
'cache-control': 'public, max-age=31536000, immutable',
}
Gotchas:
AVIF encoding is slow — 5–20× a WebP encode. Never encode on the request path without a cache; encode once, store the result (R2), and serve from there. The existing
cached()helper is the wrong tool (it caches values in memory); this needs object storage.Vary: Acceptfragments the CDN cache. Cloudflare handles it, but confirm the cache-hit ratio does not collapse after rollout.AVIF is worse than WebP for small flat-colour images (icons, logos, sprites). Gate on source dimensions: below ~200×200, keep WebP.
Verify: curl -H 'Accept: image/avif,image/webp,*/*' -sI <img-url> returns
content-type: image/avif and vary: accept; compare transferred bytes for the
feed’s first ten images.
OPT-23 — ThumbHash placeholders stored in the database¶
Category: Images · Impact: M · Effort: L · Risk: low
Evidence:
components/ui/BlurImage.tsxexists and is used bycomponents/user-builds/*, but it derives its placeholder from the optimizer (isOptimizable()gates it), i.e. it costs a network request for the blur.Prior art: Medium invented the technique; Instagram, Unsplash, Next.js
placeholder="blur"(which inlines a base64 blur at build time), Wolt’s ThumbHash.
A ThumbHash is ~25 bytes and decodes to a 32×32 preview entirely on the client. Inlined in the SSR payload, the placeholder is present at first paint with zero requests — and it prevents CLS because the aspect ratio comes with it.
Implementation
Schema — one column beside every image URL:
model FeedPostImage { // … url String width Int height Int /// ThumbHash of the image, base64. ~25 bytes decoded; rendered as the /// placeholder before the real image loads. Null for legacy rows — the /// component falls back to a plain skeleton. thumbHash String? }Compute on upload, in the same server path that already resizes:
import { rgbaToThumbHash } from 'thumbhash'; import sharp from 'sharp'; // or @napi-rs/canvas, already a dependency const { data, info } = await sharp(buffer) .resize(100, 100, { fit: 'inside' }) .ensureAlpha() .raw() .toBuffer({ resolveWithObject: true }); const hash = Buffer.from(rgbaToThumbHash(info.width, info.height, data)).toString('base64');
Client — decode to a data URL once, memoized:
const placeholder = useMemo( () => (thumbHash ? thumbHashToDataURL(base64ToBytes(thumbHash)) : undefined), [thumbHash], );
Backfill with a script alongside the existing
scripts/backfill-*.tsfamily.
Gotcha: the decode is cheap but not free (~0.2 ms each). On a feed of 30 images that is 6 ms of main thread — do it lazily as each card enters the viewport, not for the whole list at mount.
Verify: disable the network after first paint — placeholders should still appear. CLS p75 in RUM for image-heavy routes.
OPT-24 — Build-time responsive variants for public/images/**¶
Category: Images · Impact: L · Effort: L · Risk: low
Evidence:
components/ui/OptimizedImage.tsx#buildOptimizedUrl: “Local/static paths (e.g./images/...) — serve as-is, no optimization available”. Game art, icons and social images are shipped at one size to every device.Prior art:
vite-imagetools, Astro’s asset pipeline, Gatsby’s image plugin,next/imagestatic imports.
Every phone downloads desktop-sized game art. This is the largest remaining un-optimized image class on the site.
// vite.config.ts — add to plugins
import { imagetools } from 'vite-imagetools';
imagetools({
defaultDirectives: (url) => {
// Only game/app art gets variants; icons and OG cards are excluded — an OG
// card must stay at its declared dimensions (lib/og relies on them) and an
// icon has no responsive story.
if (!url.pathname.includes('/images/games/')) return new URLSearchParams();
return new URLSearchParams({
format: 'avif;webp',
w: '320;640;960;1280',
as: 'picture',
});
},
}),
Then a static import yields a ready-made <picture> source set:
import heroArt from '@/public/images/games/void-breaker.png?as=picture';
<OptimizedImage picture={heroArt} alt="Void Breaker" sizes="(max-width: 768px) 100vw, 640px" />;
Gotchas:
Build time. Encoding four widths × two formats for every piece of game art is minutes, not seconds. Cache the output —
vite-imagetoolscaches by content hash; make sure that cache directory is in the Docker build cache mounts (seedocs/opti/build-deploy-speedup.md).docs/opti/plan.md§0.1 flags 743 MB of git-tracked public assets. Generated variants must not be committed — emit to.outputand add to.gitignore/.dockerignore.Anything referenced by string path (not imported) is untouched. Sweep for
src="/images/games/and convert those call sites, or the plugin silently does nothing for them.
Verify: on a 390 px-wide viewport, the games index should transfer the 320 w
variants. Compare total image bytes on /games before/after.
OPT-25 — Cloudflare Image Resizing in front of R2¶
Category: Images · Impact: L · Effort: M · Risk: medium
Evidence:
lib/storage/asset.tsserves R2 objects throughcdn.rmhstudios.comverbatim — one size, one format, no transform./api/image-proxy(inOptimizedImage.tsx) routes external images through the origin, i.e. the Node SSR tier does image work.Prior art: every large site uses an edge image service — Cloudflare Images, Cloudinary, imgix, Fastly IO.
Two wins at once: transforms happen at the edge (never on the VPS), and the result is cached at the edge (never re-fetched from R2).
// lib/storage/asset.ts — extend `asset()` with an optional transform.
/**
* Resolve a public/-relative path to its CDN URL, optionally through Cloudflare
* Image Resizing. The /cdn-cgi/image/ prefix is handled by the edge BEFORE the
* origin is consulted, so a transformed variant costs the origin nothing after
* the first fill.
*
* Returns the untransformed URL when no CDN is configured (local dev), so this
* is safe to call unconditionally.
*/
export function assetImage(
path: string,
opts?: { width?: number; quality?: number; format?: 'auto' | 'avif' | 'webp' },
): string {
const p = path.startsWith('/') ? path : `/${path}`;
if (!CDN_BASE) return p;
if (!opts) return CDN_BASE + p;
const params = [
opts.width && `width=${opts.width}`,
`quality=${opts.quality ?? 80}`,
`format=${opts.format ?? 'auto'}`,
'fit=scale-down',
]
.filter(Boolean)
.join(',');
return `${CDN_BASE}/cdn-cgi/image/${params}${p}`;
}
Gotchas:
Image Resizing is a paid Cloudflare feature and is billed per unique transformation. Cap the width list to the seven in
OptimizedImage.WIDTHS— an uncappedwidthquery param from user input is a cost-amplification bug, so validate against the allowlist server-side./cdn-cgi/image/only works on a zone with the feature enabled; it 404s otherwise. Add a startup assertion rather than discovering it in production.This partially overlaps OPT-24. Prefer build-time variants for assets you ship (deterministic, free); prefer edge resizing for user-uploaded content (unbounded, can’t be pre-built). Do not do both for the same class of image.
Verify: curl -sI 'https://cdn.rmhstudios.com/cdn-cgi/image/width=320,format=auto/images/games/x.png'
returns cf-resized: and a content-type reflecting negotiation.
OPT-26 — KTX2/Basis texture compression for the 3D games¶
Category: Media · Impact: L · Effort: L · Risk: medium
Evidence:
public/textures/ships raw textures;three,@react-three/fiber,@react-three/dreiand@react-three/rapierare all inheavyExternalsinvite.config.ts.docs/3d-performance-audit.mdcovers render cost but the asset pipeline is uncompressed.Prior art: Google’s model-viewer, Sketchfab, PlayCanvas and Babylon.js all default to KTX2; glTF’s
KHR_texture_basisuextension exists for this.
A PNG texture is decompressed to raw RGBA in GPU memory — a 2048² PNG that downloads as 3 MB occupies 16 MB of VRAM. KTX2/Basis stays compressed on the GPU: roughly 4 MB of VRAM for the same texture, and it decodes far faster than PNG.
# Offline, committed as build output (not to git — see OPT-24 gotcha 2)
toktx --t2 --encode uastc --uastc_quality 2 --zcmp 18 --genmipmap \
out/rock_albedo.ktx2 src/rock_albedo.png
// lib/three/ktx2.ts
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
/**
* Shared KTX2 loader. The transcoder WASM is fetched from our own origin (not a
* CDN) so it is covered by the service worker's static-asset caching and the
* CSP's `worker-src 'self' blob:`.
*
* `detectSupport` must be called with the real renderer: the transcoder picks a
* GPU format (BC7/ETC2/ASTC) from what the device reports, and without it every
* device silently falls back to the largest format.
*/
export function createKtx2Loader(renderer: THREE.WebGLRenderer) {
return new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
}
Gotchas:
The transcoder is ~250 KB of WASM. It must load only on 3D routes — put it behind the same
lazy()boundary the scene already uses, or it lands in the entry and undoes OPT-01.UASTC vs ETC1S is a quality/size decision per texture: ETC1S for albedo and UI, UASTC for normal maps (ETC1S wrecks normals).
Some mobile GPUs report support they cannot actually sustain. Keep a
?notranscodeescape hatch for debugging.
Verify: renderer.info.memory.textures and the browser’s GPU memory counter
before/after; plus time-to-first-frame on a mid-range Android.
OPT-27 — Adaptive-bitrate delivery for RMHTube¶
Category: Media · Impact: L · Effort: XL · Risk: medium
Evidence:
server/rmhtube/is a dedicated Node service on port 7003;react-playeris inheavyExternals. There is no HLS/DASH packaging step inscripts/and no.m3u8handling in the repo.Prior art: every video platform. YouTube, Twitch, Vimeo, Mux, Cloudflare Stream.
A single progressive MP4 means a phone on 4G downloads the desktop bitrate and buffers. HLS with a handful of renditions fixes startup time, rebuffering and bandwidth in one move.
# One-time packaging per upload — run in the Go worker fleet (supervisor), not
# on the request path.
ffmpeg -i input.mp4 \
-filter_complex "[0:v]split=3[v1][v2][v3];[v1]scale=w=640:h=360[v1out];[v2]scale=w=1280:h=720[v2out];[v3]scale=w=1920:h=1080[v3out]" \
-map "[v1out]" -c:v:0 libx264 -b:v:0 800k -maxrate:v:0 856k -bufsize:v:0 1200k \
-map "[v2out]" -c:v:1 libx264 -b:v:1 2800k -maxrate:v:1 2996k -bufsize:v:1 4200k \
-map "[v3out]" -c:v:2 libx264 -b:v:2 5000k -maxrate:v:2 5350k -bufsize:v:2 7500k \
-map a:0 -map a:0 -map a:0 -c:a aac -b:a 128k -ac 2 \
-f hls -hls_time 4 -hls_playlist_type vod -hls_flags independent_segments \
-master_pl_name master.m3u8 \
-var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2" \
out/stream_%v.m3u8
Interim, near-free win while ABR is unbuilt:
{
/* `preload="metadata"` fetches only the moov atom (a few KB) instead of
buffering the file; `poster` gives an LCP candidate that is an image rather
than a video frame the browser must decode. Both are one-line changes and
together they are most of the perceived win. */
}
<video preload="metadata" poster={posterUrl} playsInline />;
Gotchas:
HLS needs
hls.jseverywhere except Safari (which plays it natively).hls.jsis ~100 KB — load it dynamically and only when!video.canPlayType('application/vnd.apple.mpegurl').Segment files multiply object count in R2 dramatically (a 10-minute video → ~150 objects). Budget for it and set a lifecycle rule.
CSP
media-src 'self' blob: https:already permits this;worker-src 'self' blob:covershls.js’s workers. No CSP change needed — confirm before shipping.
Verify: throttle to Fast 3G and play — the player should start at the 360p rendition within ~2 s and step up, rather than buffering at 1080p.
OPT-28 — Opus transcodes and range requests for RMHMusic¶
Category: Media · Impact: M · Effort: L · Risk: low
Evidence:
package.jsoncarries@audio/decode,wasm-audio-decoders,@wasm-audio-decoders/ogg-vorbisandtone;public/music/holds the audio. There is no transcode step inscripts/.Prior art: Spotify (Ogg Vorbis/AAC ladders), SoundCloud, Bandcamp.
Opus at 96 kbps is transparent for most listening and roughly half the bytes of 128 kbps MP3. And because the client already ships WASM decoders, the decode path exists.
ffmpeg -i track.flac -c:a libopus -b:a 96k -vbr on -application audio -f ogg track.opus
Serve with byte-range support so seeking does not re-download:
// A range-aware audio handler. `Accept-Ranges` + 206 is what makes a seek cheap;
// without it, dragging the scrubber refetches the file from zero.
const range = request.headers.get('range');
if (range) {
const [start, end] = parseRange(range, size);
return new Response(stream(start, end), {
status: 206,
headers: {
'content-range': `bytes ${start}-${end}/${size}`,
'accept-ranges': 'bytes',
'content-length': String(end - start + 1),
'content-type': 'audio/ogg; codecs=opus',
'cache-control': 'public, max-age=31536000, immutable',
},
});
}
Gotcha: keep an AAC/MP3 fallback for older Safari on iOS < 17. Negotiate via
<source> elements rather than UA sniffing.
Verify: transferred bytes per track; then drag the scrubber and confirm a
206 in the Network panel rather than a fresh 200.
OPT-29 — loading / decoding / dimension codemod for raw <img>¶
Category: Images · Impact: M · Effort: M · Risk: low
Evidence: raw
<img>tags appear across routes — e.g.app/routes/rmhcode/index.tsx:138,app/routes/_site/admin/users.tsx:244,app/routes/_site/admin/albums/$id.tsx:256— with inconsistent attributes.docs/opti/plan.md§3.2 already flags “standardize on the existing image component” as an open item.Prior art:
eslint-plugin-jsx-a11yand@next/next/no-img-elementboth enforce this class of rule.
Missing width/height is a direct CLS cause; missing loading="lazy" is a
direct bandwidth cost.
Add a lint rule so it cannot regress, then fix the reported sites:
// eslint.config.mjs
{
rules: {
'no-restricted-syntax': [
'warn',
{
// Every <img> needs intrinsic dimensions (CLS) — an aspect-ratio class
// is not enough, the browser needs them before CSS is applied.
selector:
"JSXOpeningElement[name.name='img']:not(:has(JSXAttribute[name.name='width'])):not(:has(JSXAttribute[name.name='srcSet']))",
message:
'Raw <img> needs width+height (CLS) — or use <OptimizedImage>, which sets them.',
},
],
},
}
Gotcha: pnpm lint runs jsx-a11y at warn and the quality bar is “add no new
warnings”. Adding a rule that fires 60 times immediately breaks that contract for
everyone — land the rule and the fixes in one PR, or start it as an
--report-unused-disable-directives allowlist.
Verify: pnpm lint warning count unchanged; CLS p75 on admin and app routes.
F. Runtime and interaction latency (INP)¶
OPT-34 — scheduler.yield() in long input handlers¶
Category: Runtime/INP · Impact: L · Effort: M · Risk: low
Evidence:
grep -rn 'scheduler.yield\|isInputPending' lib/ components/ app/is empty.requestIdleCallbackappears only in the deferred-fonts script and a platform type declaration.Prior art: Chrome’s own INP guidance; Wix and eBay published large INP wins from exactly this; the API shipped in Chrome 129.
INP measures the longest task between input and next paint. A handler that does several things in a row — optimistic update, analytics, cache write, re-render — is one long task. Yielding between the steps lets the browser paint the part the user is waiting for first.
// lib/scheduler.ts
/**
* Yield to the browser so it can paint and process pending input, then continue.
*
* Prefers `scheduler.yield()`, which returns to the SAME task queue position —
* unlike `setTimeout(0)`, which goes to the BACK of the queue and can starve the
* continuation behind unrelated work. That difference is the whole reason the
* API exists; the fallback is strictly worse but universally available.
*/
export function yieldToMain(): Promise<void> {
const s = (globalThis as { scheduler?: { yield?: () => Promise<void> } }).scheduler;
if (s?.yield) return s.yield();
return new Promise((r) => setTimeout(r, 0));
}
Apply where a click does more than one thing — the canonical case here is the
optimistic-action primitive the site already has (docs/ calls it out as a
platform primitive):
async function onLike(postId: string) {
applyOptimisticLike(postId); // 1. what the user is waiting for
await yieldToMain(); // ← let it PAINT here
void trackEngagement(postId); // 2. analytics
await yieldToMain();
void writeThroughCache(postId); // 3. cache bookkeeping
}
Gotchas:
Yielding inside a handler that reads layout after the yield can produce a double layout. Read first, yield, then write.
Do not yield inside a
pointerdownhandler that callspreventDefault()— after the yield the event is no longer cancelable.More yields is not better. Each one is a task boundary; sprinkling twenty of them makes the profile noisier and the work slower overall.
Verify: Performance panel → record the interaction → the long task should split into several short ones. Then INP p75 in RUM over a week.
OPT-35 — INP attribution and Long Animation Frames in RUM¶
Category: Observability · Impact: L · Effort: S · Risk: low
Evidence:
lib/rum.tsimports from'web-vitals', not'web-vitals/attribution', and sends only{name, value, rating, id, navigationType, path}. So INP is measured but never diagnosable — you know a route is slow and nothing about why.Prior art: the attribution build exists precisely for this; Shopify, Etsy and The Guardian all publish INP-attribution-driven workflows.
// lib/rum.ts — swap the dynamic import for the attribution build.
void import('web-vitals/attribution').then(({ onINP, onLCP, onCLS, onFCP, onTTFB }) => {
onINP((metric) => {
const a = metric.attribution;
send({
...baseFields(metric),
// The four numbers that decompose INP. Together they say WHICH phase to fix:
// - inputDelay high → main thread was busy before the handler ran
// - processingDuration → the handler itself is slow (OPT-34 territory)
// - presentationDelay → rendering/paint after the handler (CSS, layout)
inputDelay: Math.round(a.inputDelay),
processingDuration: Math.round(a.processingDuration),
presentationDelay: Math.round(a.presentationDelay),
// A CSS-selector path to the element that was interacted with. Low
// cardinality in practice and it turns "INP is bad on /" into
// "INP is bad on the like button in a feed card".
target: a.interactionTarget?.slice(0, 120),
// The script attributed by the Long Animation Frames API, when available.
script: a.longAnimationFrameEntries?.[0]?.scripts?.[0]?.sourceURL?.slice(0, 200),
});
});
// LCP attribution: which element, and how the time splits across TTFB /
// resource load delay / load duration / render delay.
onLCP((m) => send({ ...baseFields(m), element: m.attribution.element?.slice(0, 120) }));
onCLS((m) =>
send({ ...baseFields(m), shifted: m.attribution.largestShiftTarget?.slice(0, 120) }),
);
});
Gotchas:
The attribution build is larger (~2 KB more). It is already a dynamic import per the 08-04 audit, so this does not touch the critical path — keep it dynamic.
interactionTargetselectors can be high-cardinality if components use generated class names. Truncate and normalize server-side before storing.Extend
/api/rum’s zod schema for the new fields, ordefineHandlerwill reject the beacons and you will silently lose all RUM.
Verify: /api/rum samples carry the new fields; pick the worst route and
confirm the numbers point at a real handler.
OPT-36 — Move markdown and syntax highlighting to a Web Worker¶
Category: Runtime/INP · Impact: L · Effort: L · Risk: medium
Evidence:
docs/opti/plan.md§3.3 notes both markdown libraries are in use. CodeMirror (@codemirror/lang-markdown,@codemirror/view, …) runs on the main thread. Feed posts, blog articles, RMHCode and the library reader all parse markdown during render.Prior art: VS Code (tokenization in a worker), Discord, Notion, GitHub’s own markdown preview.
Parsing a long post is a synchronous main-thread task in the middle of a render. On a feed of 30 posts it is 30 of them.
// lib/markdown/worker.ts (bundled as a worker by Vite via ?worker)
import { marked } from 'marked';
import DOMPurify from 'isomorphic-dompurify';
self.onmessage = (e: MessageEvent<{ id: number; md: string }>) => {
// Sanitize IN the worker: the main thread must never receive HTML it then has
// to clean, or the win is spent on the cleanup.
const html = DOMPurify.sanitize(marked.parse(e.data.md) as string);
self.postMessage({ id: e.data.id, html });
};
// lib/markdown/client.ts
/**
* One shared worker instance, request/response correlated by id. Falls back to
* synchronous parsing when Worker is unavailable (SSR, ancient browsers) so
* callers never need to branch.
*/
let worker: Worker | undefined;
const pending = new Map<number, (html: string) => void>();
let nextId = 0;
export function renderMarkdown(md: string): Promise<string> {
if (typeof Worker === 'undefined') return Promise.resolve(renderSync(md));
worker ??= new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = (e) => pending.get(e.data.id)?.(e.data.html);
const id = nextId++;
return new Promise((resolve) => {
pending.set(id, (html) => {
pending.delete(id);
resolve(html);
});
worker!.postMessage({ id, md });
});
}
Gotchas:
SSR already renders this markdown. If the server output is correct, the client should not re-parse at all — check that first; the best worker is the one you do not need. This idea is for client-side composition previews and dynamically-loaded content.
worker-src 'self' blob:is already in the CSP — no change needed.Transferring large strings has a cost. Below ~2 KB, parsing synchronously is faster than the round trip. Gate on length.
Verify: Performance panel while scrolling a long thread — main-thread scripting during scroll should drop; the worker thread shows the parse.
OPT-37 — OffscreenCanvas for game render loops¶
Category: Runtime/INP · Impact: L · Effort: XL · Risk: medium
Evidence: 18 browser games, several 3D, all rendering on the main thread (
three,pixi.jsare client-side, main-thread by default).Prior art: Figma, Google Earth web, Photopea, Babylon.js’s official OffscreenCanvas mode.
Moving the render loop to a worker means a garbage-collection pause or a slow React update in the surrounding UI cannot drop a frame, and vice versa.
// The main thread hands the canvas over exactly once and never draws again.
const offscreen = canvasRef.current!.transferControlToOffscreen();
const worker = new Worker(new URL('./render-worker.ts', import.meta.url), { type: 'module' });
worker.postMessage({ type: 'init', canvas: offscreen, dpr: devicePixelRatio }, [offscreen]);
// Input still arrives on the main thread; forward it as plain messages.
// Do NOT forward every pointermove — coalesce to one message per frame or the
// postMessage traffic becomes the new bottleneck.
Gotchas:
transferControlToOffscreen()is irreversible for that canvas element. Resize handling, screenshots and CSS-driven effects all have to move too.Workers have no DOM. Any code reaching for
document(three.js’s loaders do, for textures) needs the worker-safe path —ImageBitmapLoaderrather thanTextureLoader.Safari shipped it relatively recently; keep the main-thread path behind a capability check and treat the worker path as an enhancement.
Start with one game, ideally a 2D pixi one, and measure before doing more.
Verify: Performance panel with 6× CPU throttle — frame rate should stay stable while React re-renders the surrounding HUD. Compare dropped frames.
OPT-38 — Virtualize comments, leaderboards and the library grid¶
Category: Runtime · Impact: M · Effort: M · Risk: low
Evidence:
@tanstack/react-virtualis used in exactly one place —components/feed/FeedList.tsx. Comment threads, leaderboard tables, the library grid and the games index all render every row.Prior art: every list-heavy product.
The hard problems (SSR-matching first render, back-nav measurement caching,
scroll restoration) are already solved in FeedList.tsx, including the
savedMeasurements round-trip and the hasClientMounted fast path. Extract that
into a reusable component rather than re-deriving it three times:
// components/ui/VirtualList.tsx
/**
* The virtualization pattern proven in components/feed/FeedList.tsx, extracted.
*
* Non-obvious requirements this preserves (all learned the hard way in FeedList):
* - The FIRST client render must be non-virtualized so SSR HTML hydrates without
* a mismatch; flip to virtualized after mount.
* - A back-nav remount must start virtualized (skip the one-shot pass) or the
* page flashes a full-length list.
* - Measured row heights must round-trip across remounts, or scroll restoration
* lands in the wrong place.
*/
Then apply to components/feed/CommentThread, the ladder/leaderboard tables and
app/routes/_site/library/index.tsx.
Gotcha: nested virtualization (a virtualized comment thread inside a virtualized feed) is a known footgun — the inner list’s measurement invalidates the outer one continuously. Do not nest; flatten the thread into one list with indentation instead.
Verify: DOM node count on a 300-comment thread before/after; scroll a leaderboard with 6× CPU throttle and compare frames-per-second.
OPT-39 — Passive listeners and touch-action audit¶
Category: Runtime/INP · Impact: M · Effort: S · Risk: low
Evidence:
hooks/providesuseFluidPress/useFluidDrag/useLiquidBackground— all pointer-driven. Nothing in the repo asserts these register listeners passively.Prior art: Chrome has warned about non-passive scroll-blocking listeners since 2016; every scroll-performance guide leads with it.
A non-passive touchstart/wheel listener forces the browser to wait for the
handler before it can scroll — the classic “scroll feels stuck” symptom.
// Non-passive by default for touchstart/touchmove/wheel — the browser must
// assume you might call preventDefault(). Say so explicitly when you won't:
el.addEventListener('touchstart', onTouchStart, { passive: true });
el.addEventListener('wheel', onWheel, { passive: true });
Where a gesture genuinely needs to cancel scrolling (a drag handle, a game canvas), express it in CSS instead — the compositor honours it without consulting JS:
/* The drag handle owns horizontal gestures; vertical page scroll still works
and never waits on JS. */
.drag-handle {
touch-action: pan-y;
}
.game-canvas {
touch-action: none;
}
Find offenders:
grep -rn "addEventListener('\(touchstart\|touchmove\|wheel\)'" --include=*.ts --include=*.tsx \
hooks/ lib/ components/ | grep -v 'passive'
Gotcha: { passive: true } makes preventDefault() a no-op with a console
warning. If a gesture stops working after this change, the fix is touch-action,
not reverting to non-passive.
Verify: Lighthouse “Does not use passive listeners” audit passes; on a real phone, scroll a game page and a feed and compare responsiveness.
OPT-40 — IndexedDB read-through cache for the feed¶
Category: Offline · Impact: M · Effort: L · Risk: medium
Evidence:
public/sw.jscaches build assets and images but explicitly never intercepts/api/. React Query holds data in memory only, so a reload starts cold every time.Prior art: Twitter/X, Reddit and Slack all restore the last-seen timeline from local storage instantly, then reconcile.
The user sees their last feed instantly on reload while the network fetch is in flight — perceived load time goes to roughly zero for returning visitors.
// lib/offline/feed-store.ts
/**
* Persist the last N feed items to IndexedDB and rehydrate React Query from them
* on boot. IndexedDB (not localStorage): the writes are off the main thread, and
* localStorage's synchronous API is itself an INP hazard on a large payload.
*
* Correctness rule: persisted data is a PLACEHOLDER, never an answer. It is
* rendered with `isStale` semantics and replaced by the network result as soon
* as it arrives; a persisted item is never used to decide anything (permissions,
* counts, entitlements).
*/
export async function persistFeed(items: FeedItem[]): Promise<void> {
const db = await openDb();
const tx = db.transaction('feed', 'readwrite');
await tx.store.put({ key: 'timeline', items: items.slice(0, 30), ts: Date.now() });
await tx.done;
}
Gotchas — why this is medium risk:
Privacy. Persisted feed content survives sign-out. Clear the store in the sign-out path and on a user-id mismatch at boot, or user A’s feed can flash in front of user B on a shared device. This is the failure mode to design against first.
Storage quota is shared with the SW caches. Cap hard (30 items, no media).
Stale content must be visibly stale-tolerant: never render a like-count or a permission-dependent control from persisted data.
Verify: load /, go offline, reload — the last timeline renders with an
offline indicator. Then sign out and confirm the store is empty in DevTools →
Application → IndexedDB.
G. Caching and the edge¶
OPT-41 — A cache option on defineHandler¶
Category: Caching · Impact: XL · Effort: M · Risk: medium
Evidence:
lib/api/handler.server.tsimplements session → rate limit → zod → try/catch.grep -n 'cache-control\|etag' lib/api/handler.server.tsreturns nothing. So every one of the site’s API responses is uncacheable by default, at every layer.Prior art: every framework’s route config — Next.js
revalidate, Remixheaders, Fastify@fastify/caching.
defineHandler is described in CLAUDE.md as “the only place that order is
written down in code”. Caching belongs in the same place, for the same reason:
one declaration, applied consistently, impossible to get subtly wrong per-route.
// lib/api/handler.server.ts
/**
* Declarative response caching. Omitted → today's behaviour (no cache headers).
*
* `visibility` is the safety-critical field and has no default: a handler must
* SAY whether its response is per-user. `'private'` responses are never shared
* by the CDN; `'public'` responses must be identical for every caller, which in
* practice means `auth: 'none'` or `'optional'` with no user-dependent branch.
*/
export interface CacheSpec {
visibility: 'public' | 'private';
/** Browser freshness, seconds. */
maxAge: number;
/** Shared-cache (CDN) freshness, seconds. Ignored when visibility is private. */
sMaxAge?: number;
/** Serve-stale window while revalidating, seconds. */
staleWhileRevalidate?: number;
/** Request headers the response varies on, beyond the defaults. */
vary?: string[];
}
function cacheHeaders(spec: CacheSpec): Record<string, string> {
const parts = [spec.visibility, `max-age=${spec.maxAge}`];
if (spec.visibility === 'public' && spec.sMaxAge != null) parts.push(`s-maxage=${spec.sMaxAge}`);
if (spec.staleWhileRevalidate != null)
parts.push(`stale-while-revalidate=${spec.staleWhileRevalidate}`);
return {
'cache-control': parts.join(', '),
// `Vary: Cookie` on a private response is what stops a shared cache keying
// one user's response for another. It is cheap insurance and always correct.
vary: [
'Accept-Encoding',
...(spec.visibility === 'private' ? ['Cookie'] : []),
...(spec.vary ?? []),
].join(', '),
};
}
Usage — the whole point is that it reads as one line at the call site:
// A public, slow-changing list: 60s at the browser, 5min at the edge, serve
// stale for an hour while revalidating.
GET: defineHandler(
{ auth: 'none', rateLimit: 'read', cache: { visibility: 'public', maxAge: 60, sMaxAge: 300, staleWhileRevalidate: 3600 } },
async () => Response.json(await listPublicGames()),
),
Gotchas — this is where cache bugs become security bugs:
A
publicresponse from an authenticated handler is a data leak. Add a runtime assertion: ifvisibility === 'public'andauthis'required'or'admin', throw at module load, not at request time. Make it impossible.stale-while-revalidatemeans users can see data up tosMaxAge + swrold. Anything a user just wrote must not be cached publicly — mutations should respondno-storeand the write path should purge.Roll out route by route, starting with
auth: 'none'reads.
Verify: curl -sI https://rmhstudios.com/api/<route> shows the expected
cache-control and vary; then check Cloudflare’s cache-hit ratio for /api/
before/after. Add a test asserting no auth: 'required' route declares
visibility: 'public'.
OPT-42 — Weak ETag + 304 for GET API routes¶
Category: Caching · Impact: L · Effort: M · Risk: low
Evidence: no
etaghandling anywhere inlib/api/.Prior art: GitHub’s REST API (conditional requests are how their rate limit is survivable), Fastify, Express’s built-in etag.
Complements OPT-41: Cache-Control avoids the request; ETag makes the request
cheap when it must happen anyway. A 304 is ~200 bytes instead of a 40 KB feed
page, and — importantly — it still costs the origin a render unless paired with
a cheap hash, so hash the serialized payload, not the DB rows.
// lib/api/handler.server.ts — after the handler produces a Response
/**
* Weak ETag over the serialized body. Weak (W/) because we make no byte-for-byte
* guarantee across compression or minor field ordering — semantic equivalence is
* what a client needs here, and a strong ETag would break range requests we
* don't serve anyway.
*
* Only for GET/HEAD with a 200, and only when the body is already in memory —
* never buffer a stream to hash it.
*/
if ((method === 'GET' || method === 'HEAD') && response.status === 200) {
const body = await response.clone().text();
const etag = `W/"${createHash('sha1').update(body).digest('base64url')}"`;
const inm = request.headers.get('if-none-match');
if (inm && inm.split(/,\s*/).includes(etag)) {
return new Response(null, {
status: 304,
// A 304 MUST repeat the caching headers, or the client's stored entry
// expires on the old policy and you get a request storm later.
headers: { etag, 'cache-control': response.headers.get('cache-control') ?? '' },
});
}
response.headers.set('etag', etag);
}
Gotchas:
Hashing the body costs CPU on every request. For a large payload this can exceed the saving — measure, and skip above ~256 KB.
Any timestamp or
request_idin the payload makes the ETag change every time and the whole mechanism inert. Check the response shape first.Content-Encodingmust not be part of the hash; hash before compression.
Verify: curl -sI for the etag, then
curl -sI -H 'If-None-Match: <etag>' <url> → HTTP/2 304.
OPT-43 — Extend anonymous-HTML edge caching past /¶
Category: Edge · Impact: XL · Effort: M · Risk: medium
Evidence:
server/nitro/anon-html-cache.ts:const CACHEABLE_ANON_PATHS = new Set<string>(['/'])— exactly one path, withS_MAXAGE = 30,SWR = 120.Prior art: every content site edge-caches its public pages; this is what “static site generation” achieves by other means.
The mechanism is built, audited and shipped. It covers one URL. The site has
hundreds of pages that are byte-identical for every signed-out, default-locale
visitor: /games, /apps, /blog, /blog/$slug, /news/$slug, /library,
legal pages, /optimization, and every game landing page.
// server/nitro/anon-html-cache.ts
/**
* Exact paths whose HTML is byte-identical for every signed-out, default-locale
* visitor. Keep this list audited — see the module header for the safety model.
*/
const CACHEABLE_ANON_PATHS = new Set<string>([
'/',
'/games',
'/apps',
'/blog',
'/news',
'/library',
'/about',
'/privacy',
'/terms',
'/optimization',
]);
/**
* Prefixes whose CONTENT pages are equally invariant. Separate from the exact
* set because a prefix match is a bigger promise: every current AND FUTURE path
* under it must be anon-invariant. Only add a prefix whose route tree you have
* read.
*
* Longer TTL than the homepage: a blog post does not change every 30 seconds,
* and a longer `stale-while-revalidate` means a cold edge PoP still serves
* instantly.
*/
const CACHEABLE_ANON_PREFIXES = ['/blog/', '/news/', '/games/'];
const ARTICLE_S_MAXAGE = 300;
const ARTICLE_SWR = 86_400;
Gotchas:
Personalization creep. The day someone adds “recommended for you” to the blog sidebar, every cached article serves one visitor’s recommendations to everyone. Defend with a test that renders each cacheable path twice with different anonymous request contexts and asserts byte equality.
Locale. The existing
hasLocalePreferencegate handles thermh-langcookie; confirm it also coversAccept-Language-derived rendering — if the SSR varies onAccept-Languagewithout a cookie, the cache key is wrong. Either addVary: Accept-Language(which fragments badly) or force default-locale rendering for cacheable paths.Purge on publish. Adding a blog post must purge
/blogand the sitemap. Wire a Cloudflare purge-by-URL call into the publish path.
Verify: curl -sI https://rmhstudios.com/blog/<slug> twice — second response
shows cf-cache-status: HIT. Then confirm a signed-in request shows
cf-cache-status: BYPASS and no personalized content is ever served from cache.
OPT-44 — Cloudflare Tiered Cache and Cache Reserve¶
Category: Edge · Impact: M · Effort: S · Risk: low
Evidence:
deploy/apply-cloudflare-cache-rules.shexists for cache rules;grep -n 'tiered' deploy/*.shfinds nothing.Prior art: default advice for any single-origin site; Cloudflare’s own Argo/Tiered Cache product exists because origin fan-out from ~300 PoPs is the norm otherwise.
Production is one VPS. Without tiered caching, a cache miss in each of Cloudflare’s PoPs is a separate origin request for the same object — so a cold asset can be fetched hundreds of times from a single small server. Tiered Cache inserts an upper tier so the origin sees one request.
# deploy/apply-cloudflare-cache-rules.sh — add alongside the existing rules
# Smart Tiered Cache: Cloudflare picks the upper tier automatically.
curl -sX PATCH "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/cache/tiered_cache_smart_topology_enable" \
-H "Authorization: Bearer $CF_API_TOKEN" -H 'Content-Type: application/json' \
--data '{"value":"on"}'
Cache Reserve (paid, R2-backed) additionally holds large, rarely-requested objects
— game art, audio, library PDFs — effectively forever, so they never fall back to
the VPS at all. Given docs/opti/plan.md §0.1 flags 743 MB of static assets, this
is well matched.
Gotcha: Cache Reserve is billed on storage and operations. Enable it for the
CDN hostname (cdn.rmhstudios.com) where objects are immutable, not for HTML.
Verify: origin request volume in the Apache logs for /images/** and
cdn. before/after; Cloudflare Analytics cache-hit ratio.
OPT-45 — Compression dictionaries for versioned JS¶
REJECTED 2026-08-05 — not implemented. The snippet below assumes a single-bundle app. This build emits 955 JS files into
/assets/beside 995 CSS/font/map files, and Nitro’s route matcher is segment-based, so the only expressible scope is all of/assets/**with one static header value — which would stamp a script-destination dictionary onto woff2 and CSS, and create 955 same-specificity dictionaries so the client deltas against whichever unrelated chunk it stored last. That is the exact “matches too broadly, hit rate collapses” failure this entry’s own gotcha 2 warns about. A correct policy needs a per-response header derived from each filename’s stable prefix, and the origin still cannot deliver deltas without the CDN feature. Note also that the snippet hardcodescache-control, which would pin the value and stop tracking Nitro’s immutable default. Analysis recorded invite.config.ts.
Category: Edge · Impact: L · Effort: L · Risk: medium
Evidence:
vite.config.tspre-compresses static output with gzip and documents that brotli is done at the edge. No dictionary support anywhere.Prior art: Chrome shipped Compression Dictionary Transport in 2024; Cloudflare and Akamai both support it; the canonical case study is a JS bundle update shrinking by >90%.
Between two deploys, entry-a1b2.js and entry-c3d4.js are ~99% identical. Today
a returning user downloads the whole 253 KB entry again. With a dictionary, the
browser uses the previous version it already has as the compression dictionary
and downloads only the delta — routinely a 90–95% reduction on repeat visits,
which is most visits.
# Response headers on the CURRENT asset — "you may use me as a dictionary for
# future requests matching this pattern".
Use-As-Dictionary: match="/assets/entry-*.js", id="entry-v1"
# The browser then sends, on the next deploy's request:
# Available-Dictionary: :<sha-256 of the stored dictionary>:
# Accept-Encoding: gzip, br, dcb, dcz
# and the server replies with a delta:
Content-Encoding: dcb
Vary: Accept-Encoding, Available-Dictionary
Practically: enable it at Cloudflare (it does the delta encoding) and emit
Use-As-Dictionary from Nitro’s routeRules, beside the existing /images/**
rule:
// vite.config.ts — nitro({ routeRules })
'/assets/**': {
headers: {
'cache-control': 'public, max-age=31536000, immutable',
// Scope the match tightly. A dictionary that matches too broadly makes the
// browser store many dictionaries and the hit rate collapses.
'use-as-dictionary': 'match="/assets/*.js", match-dest=("script")',
},
},
Gotchas:
Chromium-only today. It is pure enhancement — non-supporting clients get normal brotli — but do not count the win in an average.
Dictionaries consume client storage and are evicted; the benefit is statistical, not guaranteed.
Vary: Available-Dictionaryfragments intermediary caches. This only works well when the CDN itself implements it (Cloudflare does); do not attempt it at the Apache layer.
Verify: deploy twice; on the second, DevTools → Network shows
content-encoding: dcb and a transfer size a fraction of the resource size.
OPT-46 — Content-hash public/images/** so it can be immutable¶
Category: Edge · Impact: M · Effort: M · Risk: low
Evidence:
vite.config.tssets'/images/**': { headers: { 'cache-control': 'public, max-age=2592000' } }with a comment explainingimmutablewas omitted because the files are not content-hashed — a redeploy that changes an image must still revalidate.Prior art: the same trick Vite already applies to
/assets/**.
The 30-day non-immutable compromise is correct given un-hashed filenames. Hashing
them removes the compromise: one year, immutable, zero revalidations, and a
changed image is a different URL so staleness is impossible.
// A Vite plugin (or a step in scripts/) that copies public/images/** into the
// build output under a content-hashed name and emits a manifest.
// `asset()` in lib/storage/asset.ts becomes the single lookup point:
export function asset(path: string): string {
const hashed = IMAGE_MANIFEST[path] ?? path; // generated at build time
return CDN_BASE ? CDN_BASE + hashed : hashed;
}
then:
'/images/**': {
headers: { 'cache-control': 'public, max-age=31536000, immutable' },
},
Gotchas:
Every string-literal
/images/...reference must go throughasset(), or a hashed deployment 404s. Add an ESLint rule banning raw/images/string literals in JSX once the migration is done — otherwise this breaks silently months later.public/manifest.webmanifest,robots.txtand the OG images reference fixed paths that must not be hashed (external consumers link to them). Exclude/images/icons/,/images/screenshots/andog*.pngexplicitly.
Verify: curl -sI an image → cache-control: public, max-age=31536000, immutable; redeploy with a changed image and confirm the URL changed.
OPT-47 — Negative caching in cached()¶
Category: Caching · Impact: M · Effort: S · Risk: low
Evidence:
lib/cached.server.tsimplements L1+L2 with pub/sub invalidation and in-flight coalescing. There is no distinct handling for “the answer is nothing” — a lookup that misses goes to the database every time.Prior art: DNS resolvers (NXDOMAIN caching), Memcached conventions, every mature ORM cache layer.
A 404-shaped request is the cheapest to cache and most likely to be repeated — crawlers, dead links, and users hammering a deleted profile all hit the database today.
// lib/cached.server.ts
/**
* Sentinel for "we looked, and there is nothing". Cached under a SHORTER TTL
* than a hit: a missing row is far more likely to become present than a present
* row is to change, so a long negative TTL is how a newly-created resource
* appears to 404 for minutes after it exists.
*/
const NEGATIVE = Symbol.for('rmh.cache.negative');
const NEGATIVE_TTL_MS = 10_000;
export async function cachedNullable<T>(
key: string,
ttlMs: number,
fn: () => Promise<T | null>,
): Promise<T | null> {
const hit = await cached(key, ttlMs, async () => {
const value = await fn();
return value === null ? { [NEGATIVE_MARKER]: true } : value;
});
return isNegative(hit) ? null : (hit as T);
}
Gotchas:
Creating a resource must invalidate its negative entry. Wire
invalidateCached(key)into every create path that has a corresponding lookup, or “I just made it and it says not found” becomes a support ticket.Keep the negative TTL short (10 s is a good default) — it is a stampede guard, not a cache.
Verify: request a nonexistent profile 100× and count the queries in
pg_stat_statements before/after.
OPT-48 — Redis pipelining for multi-key reads¶
Category: Caching · Impact: M · Effort: M · Risk: low
Evidence:
lib/redis.server.tsexposesredisGetJSON/redisSetJSON— single-key primitives. Any code path resolving several cached values makes several sequential round trips.Prior art: every Redis client ships pipelining for exactly this; it is the standard fix for “cache is slower than the database”.
Assembling a feed page needs entitlements, sidebar data, author profiles and counts — each a separate round trip today. Pipelining collapses N round trips into one.
// lib/redis.server.ts
/**
* Fetch many keys in ONE round trip. With a 1 ms RTT, 20 sequential GETs cost
* 20 ms of pure latency for work that takes microseconds; pipelined they cost
* ~1 ms. This matters more than the Redis CPU it saves.
*
* Returns a same-length array with `undefined` for misses (never throws on a
* miss) so callers can zip it against their key list positionally.
*/
export async function redisMGetJSON<T>(keys: string[]): Promise<(T | undefined)[]> {
if (!redisEnabled() || keys.length === 0) return keys.map(() => undefined);
const raw = await client.mGet(keys);
return raw.map((v) => {
if (v == null) return undefined;
try {
return JSON.parse(v) as T;
} catch {
return undefined; // a poisoned entry is a miss, never an exception
}
});
}
Gotcha: MGET on a Redis Cluster requires all keys in one hash slot. Single
instance today (per docker-compose.yml), but if clustering ever appears this
silently breaks — use hash tags ({feed}:user:1) from the start so the keys
co-locate.
Verify: add a Server-Timing entry (OPT-49) for the cache phase and compare
before/after on the feed route.
H. Server and database¶
OPT-49 — Server-Timing headers for SSR phases¶
Category: Observability · Impact: L · Effort: S · Risk: low
Evidence:
grep -rn 'Server-Timing' lib/ server/ app/returns nothing. RUM reports TTFB as a single number with no breakdown, so a TTFB regression cannot be attributed to session lookup vs loader vs render without adding logging by hand each time.Prior art: Vercel, Netlify, Fastly and Cloudflare all emit
Server-Timing; it is the standard way to make server phases visible in DevTools and in RUM.
This is the cheapest observability win available, and it makes several other items in this document measurable.
// server/nitro/server-timing.ts — a Nitro plugin beside security-headers.ts
/**
* Emit Server-Timing for the phases that actually compose TTFB on this site:
* sess — the Better Auth session lookup (bounded at 800 ms in __root.tsx)
* loader — route loader execution
* cache — L1+L2 cache time (lib/cached.server.ts)
* db — cumulative Prisma query time
* render — React SSR
*
* Timings are collected in an AsyncLocalStorage context so any layer can add to
* them without threading a parameter through every call site. Descriptions are
* omitted in production (they cost bytes on every response and DevTools shows
* the name fine); durations are what matter.
*/
const timings = new AsyncLocalStorage<Map<string, number>>();
export function mark(name: string, ms: number): void {
const t = timings.getStore();
if (t) t.set(name, (t.get(name) ?? 0) + ms);
}
function header(t: Map<string, number>): string {
return [...t].map(([k, v]) => `${k};dur=${v.toFixed(1)}`).join(', ');
}
Add mark() calls at four places: getRequestSession(), the Prisma client
extension (lib/prisma.server.ts — an $extends query hook gives you every
query’s duration for free), cached() in lib/cached.server.ts, and around the
render in the Nitro handler.
Then surface it in RUM so it is aggregated, not just visible in DevTools:
// lib/rum.ts
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
// serverTiming is populated from the response headers, same-origin only.
for (const st of nav.serverTiming ?? []) {
send({ name: `ST_${st.name.toUpperCase()}`, value: st.duration } as never);
}
Gotcha: Server-Timing is readable by any same-origin script and is exposed
to the client. Never put user identifiers, query text or internal hostnames in
the desc field.
Verify: DevTools → Network → the document → Timing tab shows the named phases. Then: pick the slowest route and confirm the phases sum to roughly TTFB.
OPT-50 — Prisma over-fetch audit¶
Category: Database · Impact: L · Effort: L · Risk: low
Evidence:
prisma/schema.prismais 252 models / ~6k lines. AfindManywithoutselectreturns every scalar column — on wide models (posts with bodies, users with settings blobs) that is the dominant cost of a query, and it is invisible at the call site.Prior art: every ORM’s performance guide leads with this; GraphQL made field selection mandatory partly for this reason.
Make over-fetching visible rather than auditing by hand:
// lib/prisma.server.ts — a client extension that flags unbounded reads in dev.
/**
* Development-only guard. Logs any findMany that specifies neither `select` nor
* `take`, with a stack trace pointing at the call site.
*
* Not an error: a handful of unbounded reads are legitimate (small config
* tables). The point is that today they are indistinguishable from accidents.
*/
const overFetchGuard = Prisma.defineExtension({
query: {
$allModels: {
async findMany({ model, args, query }) {
if (process.env.NODE_ENV !== 'production' && !args.select && !args.take) {
console.warn(
`[prisma] unbounded findMany on ${model} — no select, no take`,
new Error().stack?.split('\n').slice(2, 5).join('\n'),
);
}
return query(args);
},
},
},
});
Fix the hot paths first — the feed, the sidebar, profiles:
// Before: returns every column of Post, including `body` (unbounded text) for a
// list view that renders 180 characters of it.
const posts = await prisma.post.findMany({ where, orderBy, take: 30 });
// After: name the columns the view renders. On a wide table this is commonly a
// 5–20× reduction in bytes off the wire from Postgres.
const posts = await prisma.post.findMany({
where,
orderBy,
take: 30,
select: {
id: true,
createdAt: true,
authorId: true,
excerpt: true, // a generated column, not the full body
likeCount: true,
replyCount: true,
author: { select: { id: true, handle: true, name: true, image: true } },
},
});
Gotcha: select and include are mutually exclusive and select is
transitive — adding it to a parent forces you to specify children. That is the
work; budget for it and do one route per commit.
Verify: enable log: ['query'] locally and compare row widths; in production
compare pg_stat_statements.total_exec_time for the feed query before/after.
OPT-51 — Read-replica routing for read-only queries¶
Category: Database · Impact: XL · Effort: L · Risk: high
Evidence:
lib/prisma.server.tsconstructs a singlePrismaPgadapter over one pool againstDATABASE_URL.grep -rn 'replica' lib/ server/finds only unrelated matches.Prior art: universal above a certain traffic level; Prisma ships
@prisma/extension-read-replicasfor it.
One Postgres serves SSR reads, API reads, the Go worker fleet, the ladder/homes pipelines and every write. Reads dominate and they contend with writes.
// lib/prisma.server.ts
import { readReplicas } from '@prisma/extension-read-replicas';
/**
* Route reads to a replica when one is configured. Writes, transactions and
* anything inside $transaction always go to the primary — the extension handles
* that automatically, which is the main reason to use it rather than a hand-rolled
* second client.
*
* `$primary()` is the escape hatch for read-after-write: any query whose
* correctness depends on data written earlier in the SAME request must use it,
* because replication lag is real and is measured in tens of milliseconds at
* best.
*/
export const prisma = process.env.DATABASE_REPLICA_URL
? basePrisma.$extends(readReplicas({ url: process.env.DATABASE_REPLICA_URL }))
: basePrisma;
Gotchas — this is the only high risk item in this document:
Read-after-write is the bug you will ship. User posts → redirect → the replica has not caught up → the post is missing → the user posts again. Every post-write read in the same user flow must use
prisma.$primary(). Enumerate those flows before enabling this, not after.Session/auth lookups must go to the primary. A just-created session that is not yet on the replica reads as “signed out”.
Streaming replication needs operational ownership: monitoring for lag, a promotion runbook in
deploy/runbooks/, and backup implications.Roll out read-by-read: start with the anonymous, already-cached paths (games index, blog) where staleness is already accepted.
Verify: pg_stat_replication.replay_lag monitored and alerting; primary CPU
and connection count before/after; then a deliberate read-after-write test for
each write flow.
OPT-52 — PgBouncer transaction pooling¶
Category: Database · Impact: L · Effort: M · Risk: medium
Evidence:
grep -n 'pgbouncer' docker-compose.yml deploy/finds nothing.lib/prisma.server.tsdocuments that the default pool of 10 “was trivially exhausted under concurrency” and was raised — and there are seven Node processes (web SSR + three hubs + three workers) plus the Go supervisor, each with its own pool, all against one Postgres.Prior art: standard for any Node deployment; Supabase, Heroku and RDS Proxy all exist because of this exact shape.
Postgres allocates a process per connection (~10 MB). Seven pools of ~20 is 140 backends for a workload whose concurrent query count is far lower. PgBouncer in transaction mode multiplexes them onto a handful of real connections.
# docker-compose.yml
pgbouncer:
image: edoburu/pgbouncer:latest
environment:
DB_HOST: postgres
DB_NAME: rmhstudios
# Transaction mode: a server connection is held only for the duration of a
# transaction, which is what makes the multiplexing work. Session mode would
# buy nothing here.
POOL_MODE: transaction
MAX_CLIENT_CONN: 500
DEFAULT_POOL_SIZE: 25
depends_on: [postgres]
Then point DATABASE_URL at PgBouncer with the flag Prisma needs:
DATABASE_URL="postgresql://user:pass@pgbouncer:6432/rmhstudios?pgbouncer=true&connection_limit=10"
Gotchas:
Transaction mode forbids prepared statements across transactions. Prisma’s
pgbouncer=truedisables them — which costs some per-query planning time. Measure; on simple queries it is negligible, on complex ones it is not.LISTEN/NOTIFYdoes not work through transaction-mode pooling. If OPT-57 lands, its listener needs a direct connection that bypasses PgBouncer.Prisma’s interactive transactions (
$transaction(async tx => …)) hold a server connection for their whole duration — long ones will starve the pool.Advisory locks and session-scoped
SETcalls break. Grep for both first.
Verify: SELECT count(*) FROM pg_stat_activity; before/after; PgBouncer’s
SHOW POOLS; for wait time; then a load test to confirm no starvation.
OPT-53 — Materialized views for leaderboards and ranked¶
Category: Database · Impact: L · Effort: L · Risk: medium
Evidence:
lib/cache.ts’s own usage example is a leaderboard cached for 30 s — i.e. the expensive aggregate is recomputed every 30 s per process, and the first request after each expiry pays for it.Prior art: every game platform; Stack Overflow’s reputation tables; Reddit’s hot-ranking materialization.
A leaderboard is a pure aggregate over immutable-ish history. Compute it in the database on a schedule, index it, and serve it with a primary-key lookup.
-- The aggregate that currently runs per cache miss, computed once per refresh.
CREATE MATERIALIZED VIEW altair_leaderboard AS
SELECT
p.user_id,
u.handle,
u.image,
MAX(p.score) AS best_score,
COUNT(*) AS games_played,
RANK() OVER (ORDER BY MAX(p.score) DESC) AS rank
FROM altair_play p
JOIN "user" u ON u.id = p.user_id
WHERE p.created_at > now() - interval '30 days'
GROUP BY p.user_id, u.handle, u.image;
-- REQUIRED for CONCURRENTLY: a unique index. Without it the refresh takes an
-- ACCESS EXCLUSIVE lock and the leaderboard is unreadable while it runs.
CREATE UNIQUE INDEX altair_leaderboard_user_idx ON altair_leaderboard (user_id);
CREATE INDEX altair_leaderboard_rank_idx ON altair_leaderboard (rank);
Refresh from the existing pg-boss job runner (server/jobs/), not a cron on the
box:
// server/jobs/refresh-materialized-views.ts
/**
* CONCURRENTLY so readers are never blocked. Takes longer and needs the unique
* index above, but a leaderboard that goes unavailable for two seconds every
* minute is worse than one that is two seconds stale.
*/
await prisma.$executeRawUnsafe('REFRESH MATERIALIZED VIEW CONCURRENTLY altair_leaderboard');
Gotchas:
Prisma does not manage materialized views. They live in a hand-written migration and are invisible to
prisma migrate diff— document them inprisma/migrations/with a README note or they will be lost in a reset.REFRESH CONCURRENTLYwrites a full new copy: disk and I/O. With 18 games’ leaderboards, stagger the refreshes rather than running them together.The view is stale by definition. Keep a live query path for “your own rank” so a player who just scored sees themselves move.
Verify: EXPLAIN ANALYZE the leaderboard endpoint’s query before/after; the
p99 of the endpoint should lose its periodic spike.
OPT-54 — pg_stat_statements review job and targeted indexes¶
Category: Database · Impact: L · Effort: M · Risk: low
Evidence:
docs/performance-audit-2026-07-17.mdcovers “DB indexes, FTS” and-07-30covers “predicate indexes” — both point-in-time passes. There is no recurring job, so index drift is only found by the next audit.Prior art: standard SRE practice; pganalyze and Datadog DBM productize it.
Make it continuous:
-- Enable once (postgresql.conf: shared_preload_libraries = 'pg_stat_statements')
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- The weekly report: what actually costs the most, by TOTAL time, not per-call.
-- A 2 ms query run 400,000 times matters more than a 900 ms query run twice, and
-- ranking by mean_exec_time hides exactly that.
SELECT
round(total_exec_time::numeric, 0) AS total_ms,
calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 1) AS pct,
left(query, 160) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 25;
Plus the two structural checks that catch most index problems:
-- Indexes that have never been used: pure write-amplification and disk.
SELECT relname, indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes WHERE idx_scan = 0 ORDER BY pg_relation_size(indexrelid) DESC;
-- Sequential scans on large tables: a missing index, or a query that should not exist.
SELECT relname, seq_scan, seq_tup_read, idx_scan, n_live_tup
FROM pg_stat_user_tables WHERE n_live_tup > 50000 AND seq_scan > idx_scan
ORDER BY seq_tup_read DESC;
Two index shapes worth applying once the report names the queries:
-- Partial: index only the rows the hot query actually filters to. Far smaller,
-- and it stays in cache.
CREATE INDEX CONCURRENTLY post_feed_idx ON post (created_at DESC)
WHERE deleted_at IS NULL AND visibility = 'PUBLIC';
-- Covering: satisfy the query from the index alone (index-only scan), no heap
-- fetch. The INCLUDE columns are stored but not part of the key.
CREATE INDEX CONCURRENTLY post_author_idx ON post (author_id, created_at DESC)
INCLUDE (excerpt, like_count);
Gotcha: always CREATE INDEX CONCURRENTLY in production — a plain
CREATE INDEX takes a write lock on the table for its duration. Prisma
migrations wrap statements in a transaction and CONCURRENTLY cannot run in one,
so this needs a raw migration with the transaction disabled.
Verify: the report as a weekly artifact; each added index justified by a
before/after EXPLAIN (ANALYZE, BUFFERS).
OPT-55 — Per-request DataLoader batching¶
Category: Database · Impact: L · Effort: L · Risk: medium
Evidence:
lib/auth-session.server.tsalready introduces request-scoped sharing (getRequestSession()is documented as request-scoped so the root loader, page loader and sidebar share one resolution). That pattern exists for one value; N+1s elsewhere are unbatched.Prior art: Facebook’s DataLoader, and every GraphQL server since.
The request-scope plumbing is already there — this generalizes it.
// lib/loaders.server.ts
import DataLoader from 'dataloader';
/**
* Request-scoped batching. Thirty feed cards each asking for their author
* produce ONE `WHERE id IN (...)` instead of thirty point queries.
*
* Must be per-request: a process-wide DataLoader caches across users and serves
* stale (or wrong-tenant) data. Create it in the same request context that
* getRequestSession() already uses.
*/
export function createLoaders() {
return {
userById: new DataLoader<string, PublicUser | null>(async (ids) => {
const rows = await prisma.user.findMany({
where: { id: { in: [...ids] } },
select: { id: true, handle: true, name: true, image: true, isVerified: true },
});
const byId = new Map(rows.map((r) => [r.id, r]));
// DataLoader REQUIRES a same-length, same-order result array. Returning a
// filtered list silently misaligns every key — the classic bug here.
return ids.map((id) => byId.get(id) ?? null);
}),
};
}
Gotchas:
Order and length of the returned array must match the keys exactly. Write a test for it; the failure mode is showing user A’s name on user B’s post.
DataLoader caches within the request. If a request writes and then re-reads the same key, clear it (
loader.clear(id)).Do not batch across a
$transactionboundary.
Verify: query count per feed render (Prisma log: ['query'] locally) — should
drop from ~1 + N to ~2.
OPT-56 — Single-round-trip feed assembly via SQL JSON aggregation¶
Category: Database · Impact: L · Effort: L · Risk: medium
Evidence:
lib/feed/splits assembly acrossmap-feed-item.server.ts,personalize.server.ts,ranking.ts,audience.server.ts,signals.server.ts— a well-factored pipeline whose stages each query.Prior art: PostgREST, Hasura and Supabase all return nested JSON from one query; it is why they are fast.
Even fully batched (OPT-55), assembling a page is several sequential round trips. Postgres can build the whole nested document in one:
-- One query returns the complete feed page, nested, ready to serialize.
-- The LATERAL joins run per-post but stay inside Postgres — the win is removing
-- N application↔database round trips, not removing the work.
SELECT json_agg(t ORDER BY t.rank_score DESC) AS page
FROM (
SELECT
p.id, p.created_at, p.excerpt, p.like_count, p.reply_count,
p.rank_score,
to_jsonb(a) - 'email' - 'stripe_customer_id' AS author,
COALESCE(m.media, '[]'::jsonb) AS media,
(l.user_id IS NOT NULL) AS liked_by_viewer
FROM post p
JOIN LATERAL (
SELECT id, handle, name, image, is_verified FROM "user" WHERE id = p.author_id
) a ON true
LEFT JOIN LATERAL (
SELECT jsonb_agg(jsonb_build_object('url', pm.url, 'w', pm.width, 'h', pm.height,
'thumbHash', pm.thumb_hash)) AS media
FROM post_media pm WHERE pm.post_id = p.id
) m ON true
LEFT JOIN post_like l ON l.post_id = p.id AND l.user_id = $1
WHERE p.deleted_at IS NULL AND p.visibility = 'PUBLIC'
ORDER BY p.rank_score DESC
LIMIT 30
) t;
Gotchas:
You lose Prisma’s type safety. Define a zod schema for the row shape and parse the result once — this is exactly the pattern
defineHandleralready uses for input, applied to a raw query’s output.to_jsonb(a) - 'email'is a denylist. A new sensitive column is exposed by default the day it is added. Prefer an explicitjsonb_build_objectallowlist — the subtraction form is shown above only because it is what people write, and it is the wrong default.This is a big, hard-to-review query. Do it for the single hottest path (the feed) and nothing else, and keep the Prisma path as a documented fallback.
Verify: EXPLAIN (ANALYZE, BUFFERS) and the Server-Timing db phase from
OPT-49 before/after.
I. Realtime¶
OPT-57 — LISTEN/NOTIFY → SSE fan-out instead of interval polling¶
Category: Realtime · Impact: M · Effort: M · Risk: medium
Evidence:
docs/performance-audit-2026-07-30.mdcovers “pollers” — so some were removed.hooks/useFeedSSEexists for the feed, but notification counts, presence and badge state are not obviously on it.Prior art: Supabase Realtime is built on exactly this; Discord and Slack push everything.
Postgres can tell the application when a row changes, so nothing needs to ask.
CREATE OR REPLACE FUNCTION notify_notification_insert() RETURNS trigger AS $$
BEGIN
-- Payload is capped at 8000 bytes; send an IDENTIFIER, never the row. The
-- listener re-reads what it needs, which also keeps permissions in one place.
PERFORM pg_notify('notification:new', NEW.user_id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER notification_insert_notify
AFTER INSERT ON notification FOR EACH ROW EXECUTE FUNCTION notify_notification_insert();
// server/jobs/notify-bridge.ts — one dedicated connection, NOT from the pool.
/**
* A LISTEN connection is session-scoped: it must never be returned to a pool or
* multiplexed through PgBouncer in transaction mode (OPT-52 gotcha 2), or the
* listener silently stops receiving.
*/
const listener = new pg.Client({ connectionString: process.env.DATABASE_DIRECT_URL });
await listener.connect();
await listener.query('LISTEN "notification:new"');
listener.on('notification', (msg) => {
// Fan out over the existing Redis pub/sub so every web instance's SSE streams
// see it — the same channel mechanism lib/cached.server.ts already uses.
void redisPublish('sse:notification', msg.payload!);
});
Gotchas:
NOTIFYis delivered at transaction commit and is not durable — a listener that is down misses it. Keep a low-frequency reconciliation poll (every 60 s) as a floor, so a missed notification costs a delay, not a loss.A high-write table can flood the channel. Debounce per user in the bridge.
Payload limit is 8000 bytes; sending row data will eventually throw.
Verify: SELECT count(*) FROM pg_stat_activity WHERE query LIKE '%notification%'
drops; measure request volume to the notifications endpoint before/after.
OPT-58 — Socket.io msgpack parser and deflate tuning¶
Category: Realtime · Impact: M · Effort: M · Risk: medium
Evidence:
server/socket-server/index.ts:234—new Server(httpServer, {…})configures path, CORS, buffer size, ping intervals andconnectionStateRecovery. There is noparser(so the default JSON parser is used) and noperMessageDeflate(so it is off by default in Socket.io v4).Prior art: Socket.io’s own docs recommend msgpack for binary-ish payloads; most game backends use a binary protocol.
Game state messages at 20–60 Hz are the site’s highest-frequency traffic.
import { Server } from 'socket.io';
import customParser from 'socket.io-msgpack-parser';
const io = new Server(httpServer, {
// …existing options…
/**
* msgpack: smaller on the wire AND faster to encode/decode than JSON for the
* numeric-heavy payloads game handlers send (positions, velocities, tick ids).
* Typically 20–40% fewer bytes on such messages.
*
* BREAKING: the client must use the SAME parser. Deploying the server first
* disconnects every live client. Ship the client change first, behind a
* version check, or accept a brief disconnect window during a maintenance slot.
*/
parser: customParser,
/**
* Compress only messages big enough to be worth a deflate pass. The default
* (disabled) is right for small frequent messages — compressing a 60-byte
* position update costs more CPU than it saves — but chat, lobby state and
* board sync are large enough to benefit.
*/
perMessageDeflate: { threshold: 1024 },
});
Gotchas:
Parser changes are a coordinated deploy. Three hubs (socket-server 7001, rmhbox 7676, rmhtube 7003) each have their own clients. Do one hub at a time.
msgpack loses
undefined/Datefidelity differently from JSON. Audit payload shapes; anything relying onJSON.stringifysemantics needs checking.perMessageDeflateholds a zlib context per connection — memory scales with concurrent sockets. Watch RSS after enabling.
Verify: WebSocket frame sizes in DevTools before/after for a game session; server CPU and RSS under a synthetic load of 100 concurrent players.
OPT-59 — Interest management and delta encoding for game state¶
Category: Realtime · Impact: L · Effort: XL · Risk: medium
Evidence:
server/socket-server/handlers/has a handler per game;laundry-sort.ts’s comment (“no state replication, no rollback, no per-frame traffic”) implies others do replicate per-frame state.Prior art: Valve’s Source engine networking, Quake 3’s delta snapshots, Colyseus’s
@filterdecorators.
Two standard techniques, both large wins where per-frame state is broadcast:
// 1. Delta encoding — send what CHANGED, not the world.
/**
* Per-client last-acknowledged snapshot; each update sends only fields that
* differ. Bandwidth becomes proportional to activity rather than to world size,
* which is the difference between 20 players and 200.
*/
function delta(prev: GameState, next: GameState): Partial<GameState> {
const out: Partial<GameState> = {};
for (const k of Object.keys(next) as (keyof GameState)[]) {
if (!Object.is(prev[k], next[k])) out[k] = next[k];
}
return out;
}
// 2. Interest management — send only what a client can SEE.
/**
* Also an anti-cheat measure, and that is the stronger argument: a client that
* never receives an out-of-view opponent's position cannot wallhack, no matter
* what it does with the data.
*/
function visibleTo(player: Player, entities: Entity[]): Entity[] {
const r2 = VIEW_RADIUS * VIEW_RADIUS;
return entities.filter((e) => (e.x - player.x) ** 2 + (e.y - player.y) ** 2 < r2);
}
Gotchas:
Delta encoding requires per-client state on the server — memory grows with player count, and a reconnect needs a full snapshot to re-baseline.
Interest management causes pop-in at the boundary. Use hysteresis (a slightly larger radius to keep an entity than to add it) or entities flicker at the edge.
connectionStateRecovery(already enabled) replays missed packets — deltas replayed against the wrong baseline corrupt state. Version each baseline and fall back to a full snapshot on mismatch.
Verify: bytes/second per connected player in a full lobby before/after; confirm no desync over a 10-minute session with deliberate network blips.
J. CI and quality gates¶
OPT-60 — Lighthouse-CI budgets on pull requests¶
Category: Build/CI · Impact: L · Effort: M · Risk: low
Evidence:
.github/workflows/synthetic-perf.ymlexists (perCLAUDE.md, it is a live-production preflight perdocs/performance-slo.md§”Live-production synthetic preflight”) — so performance is measured after merge, against production. Nothing measures a PR.Prior art: Lighthouse CI is the reference implementation; used by Google, the BBC and most large content sites.
Post-merge detection means the regression is already live and the bisect is manual. Measuring the PR moves it left.
# .github/workflows/lighthouse-ci.yml
name: lighthouse-ci
on: pull_request
jobs:
lhci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version-file: .nvmrc }
- run: corepack enable && pnpm install --frozen-lockfile
- run: pnpm build
# The app needs Postgres to SSR. Run against a seeded ephemeral DB, or
# against a small set of routes that render without one.
- run: pnpm exec lhci autorun
// lighthouserc.cjs
module.exports = {
ci: {
collect: {
startServerCommand: 'pnpm start',
url: ['http://localhost:7005/', 'http://localhost:7005/games', 'http://localhost:7005/blog'],
// Three runs and take the median: single-run Lighthouse numbers on shared
// CI runners are noisy enough to fail randomly, which trains everyone to
// ignore the check.
numberOfRuns: 3,
settings: { preset: 'desktop' },
},
assert: {
assertions: {
// Assert on RESOURCE BUDGETS, not on the composite score. Scores move
// when Lighthouse updates its weighting; byte budgets do not.
'resource-summary:script:size': ['error', { maxNumericValue: 1_080_000 }],
'resource-summary:stylesheet:size': ['error', { maxNumericValue: 260_000 }],
'resource-summary:total:size': ['warn', { maxNumericValue: 2_500_000 }],
'largest-contentful-paint': ['warn', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'total-blocking-time': ['warn', { maxNumericValue: 300 }],
'unused-css-rules': 'off', // tracked separately by OPT-13
},
},
},
};
Gotchas:
CI runners are noisy. Use
erroronly for deterministic metrics (byte sizes, CLS); usewarnfor timing metrics or the check becomes flaky and ignored.This adds a full
pnpm buildto PR CI.docs/ci-speed-audit-2026-07-17.mdexists for a reason — reuse the build artifact from the existing build job rather than building twice.
Verify: open a PR that adds a large static import; the check fails naming the budget.
K. Discoverability¶
OPT-61 — hreflang alternates for the 16 shipped locales¶
Category: SEO · Impact: L · Effort: M · Risk: low
Evidence:
grep -rn 'hreflang' app/ lib/returns nothing, despite 16 shipped locales with RTL support and a full i18n pipeline.lib/seoprovidesbuildCanonicalbut no alternates.Prior art: Wikipedia, Airbnb, Booking.com, MDN — mandatory for any multilingual site, and Google’s docs are explicit that without it localized pages compete with each other rather than serving different audiences.
Sixteen locales of content are currently invisible as such to search engines. The English page and the Japanese page look like duplicates.
// lib/seo.ts
/**
* Emit rel=alternate hreflang for every shipped locale plus x-default.
*
* Three rules search engines actually enforce:
* 1. The set must be RECIPROCAL — every listed page must list every other,
* including itself. A one-way link is ignored wholesale.
* 2. URLs must be absolute.
* 3. `x-default` names the fallback for unmatched languages — the default-locale
* URL, which is also the canonical.
*/
export function buildAlternates(path: string): LinkDescriptor[] {
const links = LOCALES.map((locale) => ({
rel: 'alternate',
hrefLang: locale,
href: absoluteUrl(locale === DEFAULT_LOCALE ? path : `/${locale}${path}`),
}));
links.push({ rel: 'alternate', hrefLang: 'x-default', href: absoluteUrl(path) });
return links;
}
The sitemap needs the same information — search engines prefer it there:
<url>
<loc>https://rmhstudios.com/blog/post</loc>
<xhtml:link rel="alternate" hreflang="es" href="https://rmhstudios.com/es/blog/post"/>
<xhtml:link rel="alternate" hreflang="ja" href="https://rmhstudios.com/ja/blog/post"/>
<!-- …all 16, plus x-default -->
</url>
Gotchas — the reason this is M effort and not S:
The URLs must exist. Locale is currently carried by the
rmh-langcookie (perserver/nitro/anon-html-cache.ts), not by the path. Advertising/ja/blog/postwhen only/blog/postexists is worse than advertising nothing. Either add locale-prefixed routes first, or scope this to the routes that genuinely have per-locale URLs.hreflangcodes are BCP-47 (pt-BR, notpt_br). Validate againstlib/i18n/config.ts’sLOCALES.This interacts with OPT-43: a locale-prefixed URL is a different cacheable path with its own edge entry.
Verify: Google Search Console → International Targeting reports no
“no return tags” errors; curl -s <url> | grep hreflang | wc -l → 17.
OPT-62 — IndexNow ping on publish¶
Category: SEO · Impact: M · Effort: S · Risk: low
Evidence: the sitemap index is served with
cache-control: public, max-age=3600, stale-while-revalidate=86400(app/routes/sitemap[.]xml.ts) and there is no push mechanism —grep -rn 'indexnow' .is empty. Discovery is entirely crawler-pull.Prior art: Bing, Yandex, Seznam and Naver all consume IndexNow; Cloudflare offers automatic IndexNow; Wix and Duda ship it by default.
A new post is discovered whenever a crawler next visits. IndexNow makes it seconds, for a one-line POST.
// lib/seo/indexnow.server.ts
/**
* Notify IndexNow-participating engines that URLs changed. Fire-and-forget:
* indexing is never worth failing a publish over, so this must not throw and
* must not be awaited on the request path.
*
* The key file must be reachable at https://rmhstudios.com/<key>.txt containing
* exactly the key — that is how the endpoint verifies domain ownership.
*/
const KEY = process.env.INDEXNOW_KEY;
export function pingIndexNow(urls: string[]): void {
if (!KEY || urls.length === 0) return;
void fetch('https://api.indexnow.org/indexnow', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
host: 'rmhstudios.com',
key: KEY,
keyLocation: `https://rmhstudios.com/${KEY}.txt`,
// Max 10,000 per request; batch a bulk backfill.
urlList: urls.slice(0, 10_000),
}),
}).catch(() => {});
}
Call it from the publish paths: blog, news, and the ladder/news pipelines in
scripts/.
Gotchas:
Submitting URLs that 404 or are
noindexgets the domain rate-limited or ignored. Only submit URLs you just made publicly reachable.Do not submit on every edit — batch, and skip trivial changes.
Google does not consume IndexNow. Keep the sitemap accurate; this is additive.
Verify: the endpoint returns 200/202; Bing Webmaster Tools shows the
IndexNow submissions.
OPT-63 — OpenSearch description document¶
Category: SEO · Impact: S · Effort: S · Risk: low
Evidence:
grep -rni 'opensearch' app/ public/is empty, although the site has a real search feature (docs/search.md,/searchis a manifest shortcut).Prior art: Wikipedia, MDN, GitHub, Stack Overflow — all let you type their name in the address bar, press Tab, and search directly.
Twenty lines for a permanent browser-level integration.
<!-- public/opensearch.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"
xmlns:moz="http://www.mozilla.org/2006/browser/search/">
<ShortName>RMH Studios</ShortName>
<Description>Search games, apps, posts and the library on RMH Studios</Description>
<InputEncoding>UTF-8</InputEncoding>
<Image width="16" height="16" type="image/svg+xml">https://rmhstudios.com/favicon.svg</Image>
<Url type="text/html" method="get"
template="https://rmhstudios.com/search?q={searchTerms}"/>
<!-- Optional but worth having: the address bar shows suggestions as you type.
Requires a JSON endpoint returning ["query", ["s1","s2",…]] — the
OpenSearch suggestions format, NOT the site's own API envelope. -->
<Url type="application/x-suggestions+json" method="get"
template="https://rmhstudios.com/api/search/suggest?q={searchTerms}"/>
<moz:SearchForm>https://rmhstudios.com/search</moz:SearchForm>
</OpenSearchDescription>
// app/routes/__root.tsx — one more link
{ rel: 'search', type: 'application/opensearchdescription+xml',
title: 'RMH Studios', href: '/opensearch.xml' },
Gotchas:
The suggestions endpoint must return the bare OpenSearch array format, not the site’s
{ error: {...} }-style envelope. If it does not exist yet, ship the HTMLUrlalone — the suggestions element is optional.robots.txtdisallows/api/— the suggestions endpoint is fetched by the browser, not a crawler, so that is fine, but do not “fix” it by allowing the path.
Verify: visit the site in Chrome, then type rmhstudios.com in the address
bar and press Tab.
L. PWA and mobile¶
OPT-64 — Manifest: launch_handler, protocol and file handlers¶
Category: PWA · Impact: M · Effort: S · Risk: low
Evidence:
public/manifest.webmanifesthasshortcuts,screenshotsandshare_target— good coverage — but nolaunch_handler,display_override,protocol_handlersorfile_handlers.Prior art: Spotify, Figma and Excalidraw all register file/protocol handlers;
launch_handleris what stops a PWA opening a second window every time a link is clicked.
{
// Reuse the existing window instead of spawning a new one for every launch.
// Without this, clicking three shared links opens three copies of the app.
"launch_handler": { "client_mode": "navigate-existing" },
// Ordered fallback list; the first supported value wins. `window-controls-overlay`
// lets the app draw into the title bar on desktop — worth it for the
// full-screen app tier (AppShell) specifically.
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
// Custom scheme: web+rmh://game/void-breaker opens the installed app.
// The scheme MUST start with "web+" for a non-allowlisted protocol.
"protocol_handlers": [{ "protocol": "web+rmh", "url": "/handle?target=%s" }],
// Let the OS offer the app for these types. Useful for RMHMusic/RMHVibe imports.
"file_handlers": [
{
"action": "/import",
"accept": { "audio/*": [".mp3", ".ogg", ".opus", ".flac"] },
"launch_type": "single-client",
},
],
}
Gotchas:
window-controls-overlaymeans you draw the title bar. The app must handle thetitlebarAreaRectenv vars (env(titlebar-area-x), …) or content lands under the window controls. Ship it only after theAppShellaccounts for it.file_handlersrequires an/importroute that readslaunchQueue.setConsumer(...). Without it, the OS hands the app a file and nothing happens — worse than not registering.protocol_handlersneed the/handleroute to validatetargetstrictly: it is attacker-controlled input that arrives from outside the browser.
Verify: install the PWA; chrome://apps shows the handlers. Click a
web+rmh:// link and confirm it opens the existing window.
OPT-65 — Background Sync for offline writes, and the Badging API¶
Category: PWA · Impact: M · Effort: L · Risk: medium
Evidence:
public/sw.jsexplicitly never intercepts/api/and has nosynclistener. Web Push exists (lib/push/send.server.ts) butnavigator.setAppBadgeappears nowhere.Prior art: Twitter/X (offline tweet queue), Gmail, Slack; the Badging API is what puts the unread count on the dock/taskbar icon.
Two independent small features that both make the installed app feel native.
Background Sync — a post composed on a train sends itself when signal returns:
// public/sw.js
/**
* Replay queued writes when connectivity returns. The queue lives in IndexedDB
* because the SW has no persistent memory between activations.
*
* Idempotency is REQUIRED, not optional: sync can fire more than once for the
* same tag, and a retried POST that creates a second post is worse than a failed
* one. Every queued request carries a client-generated idempotency key and the
* server must dedupe on it.
*/
self.addEventListener('sync', (event) => {
if (event.tag === 'rmh-outbox') event.waitUntil(flushOutbox());
});
async function flushOutbox() {
for (const item of await readOutbox()) {
try {
const res = await fetch(item.url, {
method: 'POST',
headers: { 'content-type': 'application/json', 'idempotency-key': item.key },
body: item.body,
});
// 4xx is a permanent failure — dropping it is correct, retrying forever is
// not. Only 5xx and network errors should stay queued.
if (res.ok || (res.status >= 400 && res.status < 500)) await removeFromOutbox(item.id);
} catch {
return; // network still down — leave the rest queued, sync will fire again
}
}
}
Badging API — the unread count on the app icon:
// lib/notifications/badge.ts
/**
* Mirror the unread count onto the installed app's icon. No-op where
* unsupported (all non-installed contexts, Firefox), so callers never branch.
* Clearing on zero matters: a stale badge is worse than no badge.
*/
export function setUnreadBadge(count: number): void {
const nav = navigator as Navigator & {
setAppBadge?: (n?: number) => Promise<void>;
clearAppBadge?: () => Promise<void>;
};
if (!nav.setAppBadge) return;
void (count > 0 ? nav.setAppBadge(count) : nav.clearAppBadge?.());
}
Call it from the same place the in-app notification count updates, and from the
service worker’s push handler so the badge is right even when the app is closed.
Gotchas:
Background Sync is Chromium-only. Keep an in-page retry on
onlineas the cross-browser floor.The server must honour
Idempotency-Key. Add it todefineHandleras a first-class option (store the key + response for 24 h) rather than per-route — same argument as OPT-41.A queued write can be replayed after the user’s session expired. Handle the 401 by keeping the item and prompting on next launch, not by silently dropping the user’s post.
Verify: compose a post in airplane mode, re-enable networking, confirm it posts exactly once. Install the PWA and confirm the icon badge tracks the unread count.
Appendix A — Suggested sequencing¶
Not a schedule. This is dependency order — later items are cheaper or safer once earlier ones exist.
First, because they make everything else measurable:
OPT-49 (Server-Timing) · OPT-35 (INP/LCP attribution) · OPT-01 (bundle budget) ·
OPT-31 (bfcache reporting).
Then the cheap, high-confidence wins:
OPT-30 (bfcache eligibility — likely the single largest navigation win in this
document, for a header change) · OPT-21 (fetchpriority) · OPT-04 (speculation
prefetch) · OPT-17 (font metrics) · OPT-63 (OpenSearch) · OPT-62 (IndexNow) ·
OPT-14/15 (containment) · OPT-16 (reduced transparency) · OPT-47 (negative
caching) · OPT-44 (tiered cache).
Then the structural work:
OPT-11/12 (CSS split + critical CSS) · OPT-41/42 (defineHandler caching + ETag) ·
OPT-43 (edge-cache more paths) · OPT-18 (self-host fonts) · OPT-22/24 (image
pipeline) · OPT-50/54 (query and index audits).
Then the big bets, one at a time, each fully measured: OPT-08 (streaming SSR) · OPT-05 (prerender) · OPT-07 (React Compiler) · OPT-51/52 (replica, PgBouncer) · OPT-27 (ABR video) · OPT-37 (OffscreenCanvas) · OPT-59 (netcode).
Appendix B — Already ruled out (do not re-open)¶
From the measured audits. Re-proposing any of these wastes a cycle:
Idea |
Why not |
|---|---|
|
Verified no-op — TanStack Start’s |
Force-chunking vendors into named manual chunks |
Made rolldown scatter shared runtime and dragged the 1.3 MB three payload onto every page. Only React is pinned. |
Build-time brotli for static assets |
Added ~15 s to the build for a path only direct-to-origin requests use; the edge already serves brotli. gzip is kept. |
|
Cloudflare speaks HTTP/1.1 to origins by default; h2c would be negotiated by nothing. ( |
Cursor-tracking visual effects |
Retired 2026-08-01. Nothing tracks the cursor; nothing writes a custom property to |
Per-frame custom properties on |
Restyles the entire document — cost the globe drag 23.7 s of blocking time. ( |
Removing the 800 ms session-loader timeout |
It is a defensive cap on one unbounded dependency. The 20–32 s cold TTFB it is often credited with was an unreachable Redis. |
Appendix C — The measurement harness¶
Every idea’s Verify line assumes these. Run them before and after, and put both numbers in the PR body.
# 1. Critical-path bytes (the 2026-08-04 methodology)
pnpm build
pnpm exec tsx scripts/check-bundle-budget.ts # OPT-01
# 2. Field data — the only numbers that describe real users
# /api/rum aggregates; compare p75 by route, one week before vs after.
# 3. Lab data
pnpm exec lhci autorun # OPT-60
# 4. Server phases
curl -sI https://rmhstudios.com/ | grep -i server-timing # OPT-49
# 5. Database
# pg_stat_statements top-25 by total_exec_time # OPT-54
# EXPLAIN (ANALYZE, BUFFERS) for any query you changed
The standard this document is held to: an entry is done when its Verify line produces a number, that number is in the PR body next to the before number, and the change is reflected in RUM a week later. Anything less is a guess with a commit hash.