Feed Loading — Second Audit (client hydration weight)¶
Date: 2026-07-17
Status: Implemented (this branch)
Scope: The home feed (/) initial-load path, continuing
2026-07-16-feed-loading-optimization.md.
Context: After the first pass shipped, the initial feed load on a fresh page
was still reported as very slow. This audit re-examined the whole load path with
fresh eyes, focusing on what the first pass did not touch.
0. Where the first pass left things¶
The first pass was thorough on the server hot path and the client hydration-burst, and all of it is still in place (do not regress):
Deferred/streamed loader (
<Suspense>/Await), keyset pagination, denormalized counts, bounded reaction aggregation, batched + cached author display (getUserDisplayMap), cached viewer context (getFollowingIds,getHiddenAuthorIds,getMutedWords), anon first-page cache, routestaleTime,publishDueForUseroff the hot path, muted-words returned with the timeline (no second request).Client: LinkPreview / GifEmbed fetches gated behind
useNearViewport,content-visibilityon cards, imageloading/decodingattributes, lazy RMHark modals, idle-gated boot widgets.
The server read path is, in short, already well-optimized. What the first pass did not address is the weight of the JavaScript the feed route ships and parses on a cold load — the “non-feed elements” that keep the tab busy. Two independent audits (SSR path + client bundle) converged on three eager modules that dominate the feed route’s initial parse/hydration cost.
1. Findings¶
Measured from a production pnpm build (raw chunk bytes; gzip ≈ 25–30%):
# |
Finding |
Evidence |
Impact |
|---|---|---|---|
A1 |
The full English i18n catalog (~290 KB, all 66 namespaces) ships in the entry/critical chunk on every page. ~210 KB of it is game/app/admin namespaces the feed never renders. |
|
Largest single non-feed weight on the critical path, parsed on every cold load before hydration completes. |
A2 |
|
|
~38 KB chunk of interactive-but-idle code on the feed’s hydration path. |
A3 |
The |
|
~16 KB of palette code + fuse in the every-page entry chunk. |
(The SSR shell itself is not the bottleneck: session/entitlement resolution is request-scoped and cached, and the deferred loaders genuinely stream — the feed query is the long pole there, and it is already optimized. See §3.)
2. Changes (this branch)¶
All three findings are addressed by moving idle/at-rest code out of the feed
route’s initial chunk — the same lazy pattern the codebase already uses for
modals, the emoji picker, and framer-motion’s domMax feature set.
2.1 (A1) Split the English catalog into core + backfill¶
lib/i18n/resources.en-core.ts(new, generator-emitted) — only the core namespaces any route may paint (CORE_NAMESPACES+c-ui). Statically bundled.lib/i18n/resources.ts— the entry now imports onlyEN_CORE_RESOURCES; the full catalog isloadEnResources(), its own async chunk.lib/i18n/instances.ts— the client i18next instance initializes with the core namespaces, thenbackfillEnRest()pulls the remaining namespaces from their own chunk (non-blocking) and registers any not already present.scripts/gen-i18n-resources.ts— emitsresources.en-core.tsfromCORE_NAMESPACESwhenenis regenerated, so it can’t drift.
Why it’s hydration-safe. SSR is unchanged (the server still seeds the full
en catalog, so the streamed HTML is byte-identical). The feed route renders
only core namespaces, so its first client render matches the SSR markup exactly.
For every other namespace, the project convention is t("key", { defaultValue })
and i18n:extract writes that defaultValue into the en catalog — so a
not-yet-backfilled en key resolves to the same string the server rendered. No
mismatch; the rest of the catalog simply arrives a beat later, off the critical
path.
Measured: critical-path en weight 311 KB → ~73 KB (core chunk); the
remaining ~190 KB (resources.en-*.js) is now a backfilled async chunk.
2.2 (A2) Lazy composer — components/feed/ComposeBoxLazy.tsx¶
FeedColumn renders ComposeBoxLazy, which shows a layout-matched placeholder
(same avatar/spacing, no CLS) and mounts the real ComposeBox on browser idle
(useIdleReady), on first interaction, or immediately when a saved draft is
waiting. ThreadComposer is likewise lazy() + <Suspense>.
Measured: ComposeBox is a separate ~38 KB chunk, no longer in the feed
route’s hydration path.
2.3 (A3) Lazy command palette¶
components/site/command-palette-bus.ts(new) — the dependency-freeopenCommandPalette()+ event name, so the “Jump back in” rail and the keyboard-shortcuts sheet can trigger the palette without importing fuse.js.components/site/CommandPaletteMount.tsx(new) — a tiny always-mounted listener that lazy-mounts the real palette on idle or first ⌘K/open. The palette gained aninitialOpenprop so a ⌘K that arrives before the chunk loads still opens it once mounted (Suspense-safe).ProvidersmountsCommandPaletteMountinstead ofCommandPalette.
Measured: Providers entry chunk 59.5 KB → 48.5 KB; the palette is now a
16 KB chunk loaded on idle/⌘K. (The games/apps registries stay in the entry —
Providers and RecentsTracker use them directly for theme-exclusion, unrelated
to the palette.)
3. Verified NOT the bottleneck (so we don’t relitigate)¶
The feed SQL. For-You/Following use keyset pagination on a partial index (
rmheet_feed_scan_idx) + per-author composite indexes; counts are denormalized; author display and reactions are batched/bounded. The read path is not where large-dataset time is going in the code.First byte / shell. The root loader’s session+entitlement resolve is request-scoped and 60 s-cached; the deferred loaders stream.
4. Remaining recommendations (not done here)¶
Verify the DB indexes are actually deployed in production. The partial index (
20260716000000_add_rmhark_feed_partial_index) and the following/like indexes (20260716010000) are recent. If they are not applied, the For-You scan degrades exactly as that migration describes (“feed takes forever” when recent rows are dominated by thread/community posts). Confirm withEXPLAIN (ANALYZE, BUFFERS)that the For-You query usesrmheet_feed_scan_idx.Document
loadvs. the streamed feed. With streaming SSR the browser tab spinner stays untilgetTimeline()resolves. That is inherent to the deferred pattern and correct, but it means feed-query latency is user-visible as “still loading.” The client-weight cuts above shorten hydration; the query latency itself is bounded by the DB (see #1).Redis-backed shared caches if the web tier ever goes multi-instance (the viewer-context and anon-page caches are currently in-process).
5. Verification¶
pnpm exec tsc --noEmit, pnpm lint, pnpm exec vitest run, and pnpm build
all pass on this branch. Chunk sizes above are from the post-change build.
Manual checks to run against a live instance:
Feed paints with the composer placeholder; it swaps to the real composer on idle/focus with no layout jump; a saved draft still restores.
⌘K opens the palette (both the first time — before idle — and after).
Switch UI language to a non-English locale and back; feed + game routes render correctly (no missing strings / raw keys).