Skip to content

fix(web): faster server response + no layout shift on load and transitions - #136

Open
damianrosellen1 wants to merge 3 commits into
mainfrom
fix/web-ttfb-cls
Open

fix(web): faster server response + no layout shift on load and transitions#136
damianrosellen1 wants to merge 3 commits into
mainfrom
fix/web-ttfb-cls

Conversation

@damianrosellen1

Copy link
Copy Markdown
Collaborator

Fixes the two reported performance problems: slow initial loading / server response time, and CLS on initial load and page transitions.

Problem 1 — Server response time (TTFB)

Root cause: the proxy (middleware) blocked every page request on a siteLanguageSettings fetch. With SANITY_API_READ_TOKEN set — i.e. on any deploy with Visual Editing — that fetch always used the drafts perspective against the live API (no CDN, no shared cache). Every cold start and every 60s TTL expiry paid a full Sanity round trip before even a fully static page could be served. The render path (fetchSiteLanguageSettings) had the same token-gated drafts fetch on every render.

Fix (97f4fe4):

  • Proxy uses the published perspective via the CDN host for regular traffic; the in-memory cache serves stale-while-revalidate with a single-flight background refresh — only the very first request of a process ever waits for Sanity.
  • Drafts stay fully intact for editors: requests carrying the Draft Mode cookie (and all of dev) still resolve drafts per request, in both proxy and render path, so Presentation previews of unpublished language changes keep working.
  • fetchSiteLanguageSettings gates the drafts client on draftMode().isEnabled instead of token presence and otherwise uses the existing unstable_cached published path (tag-invalidated via /api/revalidate).

Local next start measurements (homepage, token set):

before after
cold start (first request) ~1.00s ~0.35s
steady state ~150ms ~4ms
request with draft cookie ~170ms (pays its own drafts round trip, doesn't pollute the shared cache)

The structural win matters most on serverless, where every new isolate paid the blocking fetch.

Problem 2 — CLS on initial load and transitions

Three independent causes, three fixes:

  1. Fonts (a5d9827): display: "block" hid all text for up to 3s while Geist loaded. Now swap — text paints immediately in the metrics-adjusted fallback that next/font generates, so the swap itself doesn't shift layout.
  2. Lazy LCP media (a5d9827): every image rendered loading="lazy" + fade-in from opacity: 0, including the first module's media. ModulesRenderer now threads priority into module index 0 (ModuleMedia image/video/loop poster, ModuleContentRefs first card) → eager + fetchpriority=high, no fade. Rich-text-embedded media intentionally stays lazy (mid-text images aren't reliably above the fold).
  3. Transitions (78f69f7):
    • scrollbar-gutter: stable on <html> — the scrollbar appearing/disappearing between short and scrollable pages shifted the whole layout horizontally (Windows/Linux).
    • The route-loading skeleton painted on every navigation and was then replaced by content — a visible jump, even though ISR + prefetch resolve most navigations in well under 300ms. The skeleton now stays invisible for 300ms, so fast navigations never show it; genuinely slow loads still get feedback.

Side effects / notes

  • sitemap.xml revalidate drops 1h → 60s (the language-settings read now participates in unstable_cache instead of bypassing Next's cache).
  • Ops note: a persistent Draft Mode cookie (left over from a Presentation session) makes every request render dynamically with drafts — if production felt slow while logged in, that may have compounded it. /api/draft-mode/disable clears it.

Verified

  • pnpm typecheck, pnpm format clean; production build green, all routes still SSG/ISR
  • Locale routing unchanged: /de 200, /en → 307 /, /en/docs → 307 /docs, /de/docs 200
  • Draft-cookie requests take the drafts path; anonymous requests right after stay on the cached published path
  • font-display: swap, scrollbar-gutter: stable, and the 300ms skeleton delay present in the built CSS

🤖 Generated with Claude Code

damianrosellen1 and others added 3 commits July 28, 2026 13:56
The proxy fetched siteLanguageSettings on every page request, and with
SANITY_API_READ_TOKEN set (any deploy with Visual Editing) it always used
the drafts perspective against the live API — uncached, no CDN. Every
cold start and every 60s TTL expiry blocked the response on that round
trip; locally measured 1.0s cold / ~150ms steady-state TTFB, worse on
serverless where isolates recycle constantly.

- proxy: published perspective via the CDN host for regular traffic;
  drafts only for requests that carry the Draft Mode cookie (and in dev),
  fetched per request and never written to the shared cache
- proxy: stale-while-revalidate with a single-flight refresh — an expired
  cache entry is served immediately while one background refresh runs, so
  only the first request of a process ever waits for Sanity
- render path: fetchSiteLanguageSettings gates the drafts client on
  draftMode().isEnabled (or dev) instead of token presence, falling back
  to the existing unstable_cache'd published path; draftMode() throwing
  outside request scope (generateStaticParams) is treated as disabled

Presentation previews of unpublished language changes keep working —
draft-mode requests still resolve drafts in both proxy and render path.

Side effect: sitemap.xml revalidate drops 1h -> 60s because the language
settings read now participates in unstable_cache instead of bypassing
Next's cache entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- next/font display: block -> swap. block hid all text for up to 3s
  while Geist loaded; swap paints immediately in the metrics-adjusted
  fallback next/font generates, so the swap itself doesn't shift layout.
- Thread a priority flag from ModulesRenderer (module index 0) into
  ModuleMedia (image / video / loop poster) and ModuleContentRefs
  (first card image). The first module is the LCP candidate, but every
  image rendered as loading=lazy with a fade-in from opacity 0 — the
  LCP image of a media-first page was deferred and then faded in.
  Priority media loads eager + fetchpriority=high and skips the fade.

Rich-text-embedded media (project body) intentionally stays lazy — an
image mid-text isn't reliably above the fold.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two shifts fired on client-side navigations:

- Scrollbar appear/disappear between short and scrollable pages shifted
  the whole layout horizontally on classic-scrollbar systems
  (Windows/Linux). scrollbar-gutter: stable on <html> reserves the
  gutter permanently.
- The route-loading skeleton painted on every navigation, then got
  replaced by real content — a visible skeleton -> content jump even
  though ISR + prefetch resolve most navigations in well under 300ms.
  The skeleton now stays invisible for 300ms (opacity keyframe, both
  fill mode) so fast navigations never show it; genuinely slow loads
  (draft mode, cold cache) still get feedback after the delay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@netlify

netlify Bot commented Jul 28, 2026

Copy link
Copy Markdown

Deploy Preview for bef-next-sanity-starter ready!

Name Link
🔨 Latest commit 78f69f7
🔍 Latest deploy log https://app.netlify.com/projects/bef-next-sanity-starter/deploys/6a6899464b65bc00085cb10d
😎 Deploy Preview https://deploy-preview-136--bef-next-sanity-starter.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant