Skip to content

perf(sites): built-in page cache with publish-time invalidation (#72) - #143

Merged
clbotdev merged 4 commits into
mainfrom
perf/sites-layer1-cache
Oct 25, 2026
Merged

clbotdev merged 4 commits into
mainfrom
perf/sites-layer1-cache

Conversation

@clbotdev

Copy link
Copy Markdown
Contributor

Part of #75. Closes #72. Design: docs/site-delivery.md §4 (updated to match what was built, including measurements in §9.1).

Public pages are now rendered once and served from FrontLit's own cache. Repeat views don't call the API, and a publish shows up in well under a second. It works with a plain docker compose up (no Redis, no CDN). With REDIS_URL, every replica shares the cache and every invalidation.

Results (local, same sample tenant and harness as #142)

main this PR
Throughput, home / docs article / blog post 41 / 56 / 81 req/s 140 / 181 / 242 req/s
p50 under load (10 conns), home / docs 236 / 176 ms 63 / 50 ms
Single-connection response, home / docs 22 / 26 ms 7 / 5 ms
API calls per docs view 1.0 0 (0 for every page)
Lighthouse TTFB, home / docs / blog 43 / 119 / 23 ms 17 / 10 / 7 ms
Publish → visible: page, blog, changelog, settings up to 60 s 0.18–0.35 s
Publish → visible: docs publication — (uncached) 0.43–0.54 s (was 2.4 s, see the worker wake-up below)
Second replica sees a publish (Redis) up to 60 s 0.33–0.48 s (still stale after 15 s without Redis)
Cold replica's first 6 views (Redis) 12 API calls 0

Scoped invalidation, checked by warming 6 pages and then making each change:

  • Homepage publish → only / re-renders.
  • Blog post → the post and /blog.
  • Changelog entry → /changelog.
  • Docs publication → /docs/*.
  • Settings or header change → everything.

Lighthouse (mobile, median of 5):

  • Docs article: LCP 2.64 → 2.48 s, 21 → 15 requests.
  • Blog post: score 90 → 93.
  • Home: score 91 → 87, LCP 3.37 → 3.60 s. TTFB improved, but the hero image download dominates on simulated slow 4G.
  • TBT: 143–227 → 202–381 ms. Total main-thread work is unchanged (script evaluation 690 → 666 ms on docs); the Cache Components client router runs hydration in fewer, longer tasks, and TBT counts each task's time over 50 ms.

Sites

  • Cache Components. Every public read in lib/api.ts is a "use cache" function tagged team/shell/page/docs/blog/changelog/sitemap (site profile: stale 5 min, revalidate 1 h, expire 30 d). Pages inherit those tags and are cached whole (x-nextjs-cache: HIT). generateStaticParams returns a _build placeholder, so a build needs no API.
  • No request headers in render. /embed has its own layout outside the new (site) group. Canonical host and hideFromSearch come from cached public settings. A non-canonical host gets X-Robots-Tag: noindex from the proxy. teamId comes from params, and docs are no longer no-store.
  • Detail routes depend only on the shell. Docs articles and blog posts read just the shared header and footer (getSiteChrome, shell tag), so a homepage publish doesn't re-render them.
  • Request-specific routes stay uncached. Ticket threads, PR previews and the chat embed render their token or ?layout= part inside <Suspense> and send private, no-store. Side effect: a bad ticket token now shows the noindex not-found page with status 200 instead of 404. The duplicated site chrome on ticket pages is gone.
  • cache-handler.cjs. Next's in-memory handler, plus Redis when REDIS_URL is set. Entries are shared across replicas. Tag invalidations are stored, published, and applied to each replica's tag manifest, which is what full-page hits are checked against. Redis never blocks a request: no offline queue, 500 ms timeouts, background reconnects. I tested it down at boot, stopped mid-run, and restarted.
  • Host lookups use Redis as a second tier, share one API call across concurrent misses, and invalidate on every replica.
  • /api/webhooks/revalidate takes { eventId, teamId, tags[], invalidateHosts? }. It accepts only that team's tag families and acknowledges replays without repeating them. The docs webhook now expires only docs + sitemap.
  • Links prefetch on intent. SiteLink prefetches on hover, focus or touch. With fully static pages, viewport prefetch downloaded every linked page plus its preloaded hero image; a docs sidebar alone would fetch every article.
  • Without REVALIDATE_WEBHOOK_SECRET nothing can invalidate, so pages refresh every minute (siteUnmanaged). That matches today's behaviour.

API

  • site_cache_outbox (migration 0011) replaces the best-effort revalidateTeam. Events are written in the query layer, so REST, MCP and Git sync invalidate the same way. MCP blog and settings changes never invalidated before.
  • Release activation diffs the outgoing release: pages whose slug or revision changed, plus shell when header, footer, theme or redirects changed. Delivery runs within ~50 ms of the write, retries with backoff, and records per-destination acknowledgments.
  • SITES_REVALIDATE_URL lets the API post to an internal address. Compose now sets http://frontlit-sites:3002; before, it posted to the public localhost:3002, which doesn't resolve inside the container.
  • Public settings carry canonicalHost and hideFromSearch.
  • Docs publishing. Queuing a docs publication wakes the publication worker instead of waiting for its 2 s poll.

Testing

  • API: 741 tests pass (new site-cache.test.ts). Sites: 59 tests pass (webhook tag validation and replay, host lookup dedupe, proxy forwards no request headers). Typecheck and lint are clean.
  • Browser smoke on the production build:
    • Builder publish → public page in ~0.5–0.7 s, then reverted (empty field saved, original value restored and republished).
    • Client-side navigation across home → docs → article → blog → post → changelog → back, with no full reloads and no console errors.
    • Docs search hits the live API.
    • Ticket thread renders once; a reply posts and survives reload.
    • Embed chat renders; the docked layout is transparent.
    • /nope, /blog/nope and /docs/nope return 404.

Notes

  • Multi-replica setups need the same DEPLOYMENT_ID on every instance. The public site has no Server Actions, so it needs no shared encryption key.

#72)

Public pages are now rendered once and served from the cache. Repeat
views of pages, blog and docs no longer call the API, and a publish
shows up within a fraction of a second.

Sites:
- Cache Components: every public read is a "use cache" function tagged
  team/shell/page/docs/blog/changelog/sitemap. Pages inherit the tags of
  the data they read and are cached as a whole. A _build placeholder
  satisfies generateStaticParams, so a build needs no API.
- Nothing rendered reads request headers anymore. /embed has its own
  layout outside the new (site) group. The canonical host and
  hideFromSearch come from cached public settings, and a non-canonical
  host gets X-Robots-Tag from the proxy. Docs are cached like everything
  else.
- Docs articles and blog posts read only the shared header and footer
  (shell tag), so a homepage publish doesn't re-render them.
- Ticket threads, pull request previews and the chat embed render their
  token/query part inside Suspense and stay private, no-store.
- cache-handler.cjs: Next's in-memory handler, plus Redis when REDIS_URL
  is set. Entries are shared, and tag invalidations are published and
  applied to every replica's tag manifest, so full-page hits refresh too.
  Redis never blocks or fails a request.
- Host lookups use Redis as a second tier, share concurrent misses and
  invalidate on every replica.
- The revalidate webhook takes { eventId, teamId, tags[],
  invalidateHosts? }, accepts only the team's tag families and ignores
  replays. The docs webhook expires docs + sitemap only.
- Links prefetch on intent: viewport prefetch of fully cached pages
  downloaded every linked page and its hero image.
- Without REVALIDATE_WEBHOOK_SECRET, pages refresh every minute.

API:
- site_cache_outbox replaces the best-effort revalidateTeam POST. Events
  are written by the query layer, so REST, MCP and Git sync are all
  covered. Release activation diffs the outgoing release to find the
  changed pages, and the shell when the site snapshot changed.
- SITES_REVALIDATE_URL lets containers post to the internal address.
- Public settings carry canonicalHost and hideFromSearch.
- Queuing a docs publication wakes the publication worker.
IMAGE_REMOTE_HOSTS and DEPLOYMENT_ID are read by next build, so the sites
Dockerfile now takes them as build args and compose passes
IMAGE_REMOTE_HOSTS through. DEPLOYMENT_ID only matters for separately built
replicas.
next build prerenders the sitemap route with a '-' team. Its cached read
called the API, so building the image without a running backend failed.
Any team ID that isn't team_… is now a placeholder.
@clbotdev
clbotdev merged commit cc16134 into main Oct 25, 2026
3 checks passed
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.

Public sites perf · Phase 2: built-in caching layer (cache components + shared Redis handler, docs cacheable)

2 participants