Skip to main content

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.
  • TenantContent is 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() exposes orgSlug/orgRole decoded from the session token, and TenantRedirector keeps the active org synced to the subdomain via setActive({ organization: subdomain }). When orgSlug === shortName, the role comes straight from the request cookie. Clerk normalizes the claim to org:admin / org:member (verified in @clerk/shared's jwtPayloadParser), matching all existing comparisons.
  • Tier 2 — backend fallback (first visit to a tenant that isn't the active org yet): resolve clerkOrganizationId from 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 cacheGET / (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 shellGET /en warm: 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 the AppShellSkeleton (5 MuiSkeleton-wave nodes) followed by the real content in the same stream.

  • Paint metrics (full //en flow):

    RunTTFBFCPLCP
    direct /en, warm78 ms648 ms648 ms
    via /, warm107 ms2180 ms2996 ms
    via /, cooler function688 ms1352 ms1768 ms
    via /, warm89 ms1408 ms2172 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 !userId check). 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 useEffect fetching were audited and are not on the critical path.