LCP Strategy — Streaming the Tenant Shell
Users reported a long blank white page on first load of a tenant subdomain
({shortName}.rat.gd) — before even the "Loading…" overlay appeared. Since
that overlay is an inline <script> at the top of <body>
(src/app/layout.tsx), a long blank page meant the browser had received no
bytes at all: the delay was server-side, not client-side.
This doc records the diagnosis, the fixes that shipped (July 2026), the deliberate deviations from the original plan, and how to measure regressions.
Root cause
Next.js (App Router) can only start streaming HTML once it reaches a
Suspense boundary (or finishes the full tree). Nothing short-circuited the
render between the root shell and the slowest data fetch:
GET tenant.rat.gd/
└─ src/proxy.ts extra HTTP round trip (locale lookup)
└─ GET tenant.rat.gd/{locale}
└─ src/app/layout.tsx ← overlay <script> lives here
└─ src/app/[locale]/layout.tsx ← Clerk provider, i18n messages
└─ src/app/[locale]/(app)/layout.tsx
├─ getOrganizationByShortNameRO() (Mongo)
├─ getOrgMembership() (Mongo + Clerk API)
└─ getRopaByIdRO() (Mongo)
The (app) layout awaited those three calls sequentially with no
Suspense boundary, so React could not flush any HTML — including the
overlay script three levels up — until the whole waterfall resolved (worst
case: the 5000 ms Mongo timeout). A route's loading.tsx does not help here:
it only wraps page.tsx, never the layout's own data fetching.
On top of that, src/proxy.ts paid a full extra HTTP round trip on every
cold / request: it fetch()ed /api/locale-default (itself a Mongo
lookup) before it could issue the /{locale} redirect.
What shipped
1. Suspense boundary — the shell flushes immediately
src/app/[locale]/(app)/layout.tsx is split in two:
RootLayout(the default export) is synchronous and renders only<Suspense fallback={<AppShellSkeleton />}>around the async body. It flushes to the browser within ~1 RTT, together with the root overlay script.TenantContentis the previous layout body, unchanged: org fetch (raced against the 5000 ms timeout), locale-configuration check, membership gate, ROPA fetch, then the provider tree.
AppShellSkeleton lives in src/lib/ui/skeletons/ (default export, MUI
<Skeleton animation="wave">, re-exported from the barrel). It is
deliberately message-free: TenantContent still calls redirect() in
several branches (org not available, locale mismatch), and redirect()
inside an already-flushed stream becomes a client-side redirect — the user
briefly sees the fallback, so it must look unremarkable.
A finer-grained variant ("Step 1b": painting the real navbar/drawer chrome immediately and streaming the org-dependent islands into slots) was prototyped and rejected — the added component-API complexity wasn't worth it. If revisited, note two traps found during prototyping: client context providers rendered inside the streamed content cannot re-render chrome placed outside it (the navbar would show the mock org forever), and private-org data must not be streamed into chrome slots before the membership check resolves.
2. Parallel data fetches
getOrgMembership() does not depend on the organization fetch, so the
layout kicks it off immediately after resolving the hostname and awaits it
only at the point the result is needed. Layout data time drops from
t(org) + t(membership) + t(ropa) to max(t(org), t(membership)) + t(ropa).
The ROPA fetch stays sequential by necessity — it needs ropaId from the
org document. The early promise carries a no-op .catch so a redirect in an
earlier branch can't produce an unhandled rejection.
3. Membership from session claims (src/lib/auth/requireOrgAccess.ts)
getOrgMembership previously cost a Mongo lookup + a Clerk backend API call
per invocation — and ran twice per request (layout + page). Now:
- Tier 1 — JWT fast path (zero network calls). Clerk's
auth()exposesorgSlug/orgRoledecoded from the session token, andTenantRedirectorkeeps the active org synced to the subdomain viasetActive({ organization: subdomain }). WhenorgSlug === shortName, the role comes straight from the request cookie. Clerk normalizes the claim toorg:admin/org:member(verified in@clerk/shared'sjwtPayloadParser), matching all existing comparisons. - Tier 2 — backend fallback (first visit to a tenant that isn't the
active org yet): resolve
clerkOrganizationIdfrom Mongo, then query the user's memberships (users.getOrganizationMembershipList,limit: 100) instead of the org's member list. This scales with the user's org count (typically 1–3) and fixed a latent bug: the old org-wide list used Clerk's default page size (10), silently treating members beyond the first page as non-members and bouncing them off private orgs. - The lookup is wrapped in React
cache(), so layout + page dedupe to one execution per request.
Staleness caveat: the fast path trusts the JWT, so a role change or org removal only shows up when the short-lived (~60 s) session token refreshes; a just-removed member may get one more page view. Acceptable for page gating — mutating server actions and API routes re-check auth independently.
4. Proxy locale cache (src/proxy.ts)
The root-path redirect keeps /api/locale-default as the source of truth
but memoizes its answer per host in module scope (5-minute TTL). The extra
HTTP round trip now happens at most once per host per TTL instead of on
every cold / visit. Transient failures fall back to /en and are not
cached, so the next request retries.
Why not inline the Mongo lookup? The proxy runs on the Node.js runtime
(Next 16 default), so Mongoose could run there — but the proxy
file-convention docs warn against relying on shared app modules, and the
service layer is built on unstable_cache, whose failure mode outside a
render context would silently degrade every tenant's locale to en. The
in-memory cache gets the same latency win without bundling the DB stack
into the proxy.
Measuring
Dev-server timings are not representative — use next build && next start
locally, or PageSpeed Insights / WebPageTest against a deployed *.rat.gd
tenant. Capture TTFB, FCP, LCP and which DOM node is the LCP element.
Post-fix reference measurement (2026-07-07, production)
Measured against demo.rat.gd right after the fixes deployed (Chromium via
Playwright, anonymous visitor, ~80 ms RTT; curl for raw request timings).
No pre-fix baseline was recorded, so these are the reference numbers for
future regression checks:
-
Proxy locale cache —
GET /(curl, consecutive): 3.44 s cold (Vercel cold start + Mongo lookup), then 124 ms / 126 ms from the in-memory cache. Browser redirect leg on warm runs: 66–84 ms. -
Streaming shell —
GET /enwarm: TTFB 89–107 ms while the full document takes ~1 s longer; the gap is the org/membership/ROPA fetch resolving after the shell flushed. The response contains theAppShellSkeleton(5MuiSkeleton-wavenodes) followed by the real content in the same stream. -
Paint metrics (full
/→/enflow):Run TTFB FCP LCP direct /en, warm78 ms 648 ms 648 ms via /, warm107 ms 2180 ms 2996 ms via /, cooler function688 ms 1352 ms 1768 ms via /, warm89 ms 1408 ms 2172 ms The LCP element is real content (an activity card
<h6>title), not the skeleton — typical warm runs land inside Google's "good" threshold (< 2.5 s), best case 0.65 s. -
Known remaining cost: a fully cold function still takes ~3 s to first byte — serverless cold start + Mongo connection plus the awaits in the
[locale]layout (Clerk, i18n messages), all above the Suspense boundary. Not addressable by streaming; see future options below. -
Not verified by this measurement: the JWT fast path in
getOrgMembership(anonymous visits return at the!userIdcheck). Verify with a signed-in member visit and the function logs — a warm tenant visit must show zero Clerk backend calls.
Quick regression check: Chrome DevTools → Network → throttle "Slow 4G" →
reload a tenant URL. The loading overlay / AppShellSkeleton must paint
well before the Mongo/Clerk data resolves. If the page is blank until
content appears, something is blocking the stream again — usually a new
await added above the Suspense boundary in the (app) layout, or
uncached data access in a parent layout.
Future options (deliberately not done)
- Root-path redirect elimination — resolve the locale via a proxy rewrite instead of a redirect, saving the second browser round trip. Bigger routing change; measure first.
- Cookie-persisted
primaryColor— the theme color arrives with the org data; persisting it in a cookie would allow correct branding from the first streamed byte for returning visitors. - PPR /
cacheComponents— experimental flags, revisit only if the above doesn't hit target LCP. - Image/font optimization and client-side
useEffectfetching were audited and are not on the critical path.