Skip to content

perf(sites): optional, vendor-neutral edge cache with tag purges (#73) - #144

Merged
clbotdev merged 2 commits into
mainfrom
perf/sites-edge-cache
Oct 25, 2026
Merged

clbotdev merged 2 commits into
mainfrom
perf/sites-edge-cache

Conversation

@clbotdev

Copy link
Copy Markdown
Contributor

Part of #75. Closes #73. Stacked on #143: review and merge that first, then retarget this PR to main.

Any HTTP cache or CDN can now sit in front of apps/sites using standard headers. It stays optional. Correct delivery never depends on it, because Layer 1 (#143) does the real caching. Design: docs/site-delivery.md §5, updated, with measurements in §9.1. Recipe: deploy/edge-cache/README.md.

Results (Varnish 7.6 recipe in front of the #143 origin, same sample tenant)

origin (Layer 1) through Varnish, purge mode
Throughput, home / docs article / blog post 140 / 181 / 242 req/s 4,908 / 6,215 / 7,361 req/s
p50 under load (10 conns) 36–63 ms 1 ms
API calls per view 0 0
Publish → visible at the edge (page, blog, changelog, settings) — 0.20–0.31 s
Docs publication → visible at the edge — 0.42–0.54 s
ttl mode (EDGE_CACHE_TTL=5), no purge integration — 5.0–5.1 s (capped at the TTL)
Purge receiver down for 6 s — retried, visible at 6.7 s once it was back

Edge purges are scoped by tag, measured at the edge (X-Edge-Cache):

  • Blog publish → the post and /blog.
  • Changelog publish → /changelog.
  • Docs publication → /docs/*.
  • Settings or header change → everything.

The second purge lands about 10 s after the event.

Client navigation / RSC

  • Behind Varnish as configured (Vary honored): home → docs → article → blog → post → changelog → back. No full reloads, no console errors, and RSC payloads are fetched through the edge.
  • Behind a cache that ignores Vary (VCL variant with unset beresp.http.Vary): the same two rounds pass. ?_rsc=… payloads are cached as text/x-component, separate from the HTML.
  • Bug found and fixed: behind a Vary-ignoring cache, a single request with the rsc: 1 header and no _rsc got Next's 307 redirect stored at the page's own URL. Every later visitor was redirected to an RSC URL. Responses whose rsc header and _rsc parameter disagree are now private, no-store in every mode. Next strips both signals before the proxy by default, so skipProxyUrlNormalize is on (App Router only, no /_next/data). Retested: the poisoning attempt is refused and visitors get HTML.

Changes

  • EDGE_CACHE_MODE (runtime), set in proxy.ts via lib/edge-cache.ts:
    • none (default): Next's own headers.
    • ttl: CDN-Cache-Control / Surrogate-Control s-maxage=$EDGE_CACHE_TTL, stale-while-revalidate=1d, stale-if-error=7d.
    • purge: one-year s-maxage plus Surrogate-Key and Cache-Tag, using the Layer 1 tag names derived from the path.
    • Browsers always get public, max-age=0, must-revalidate.
    • Ticket threads, previews and /embed/chat are private, no-store in every mode.
  • API:
    • An EdgePurgeAdapter interface with a generic http adapter (signed POST { teamId, tags }).
    • Both outboxes deliver sites → edge → edge-repeat. edge-repeat is held until 10 s after the event, and failures retry with backoff.
    • A docs sensitive-data purge reports deliveredAt only once every cache has acknowledged.
    • A broken edge config makes delivery fail and retry (and is logged at boot). It never drops purges or blocks a publish.
  • deploy/edge-cache: Varnish VCL using xkey, purge-receiver.mjs, and a compose override. The VCL never caches 5xx, private responses or cookies, keys on host + full URL, and keeps 7 days of grace for stale-on-error. Not part of the default stack.
  • Optional ASSET_PREFIX for /_next/static (build time).

Testing

  • API: 746 tests pass (new edge-purge.test.ts: signing, 10 s repeat, failure retry, misconfiguration). Sites: 66 tests pass (new edge-cache.test.ts: tags per path, headers per mode, private routes, mixed RSC requests).
  • End to end with Docker Varnish, the purge receiver, the API and sites, as above. The ticket page passes through Varnish uncached (private, no-store).

Any HTTP cache or CDN can now sit in front of apps/sites using standard
headers. It stays optional: FrontLit's own cache does the real work.

- EDGE_CACHE_MODE (read at runtime): none (default) keeps Next's headers;
  ttl sets CDN-Cache-Control / Surrogate-Control s-maxage=EDGE_CACHE_TTL;
  purge keeps pages until purged and adds Surrogate-Key / Cache-Tag with
  the Layer 1 tag names, computed from the path. Browsers always get
  max-age=0. Ticket threads, previews and the chat embed are private in
  every mode.
- A request whose rsc header and _rsc parameter disagree is never
  cacheable. Behind a cache that ignores Vary, Next's redirect for such a
  request was stored at the page's URL, and one crafted request sent
  every visitor to an RSC payload. skipProxyUrlNormalize gives the proxy
  both signals.
- API: an EdgePurgeAdapter interface and a generic http adapter (signed
  POST { teamId, tags }). Both outboxes deliver sites → edge →
  edge-repeat (ten seconds later) and retry failures; a docs purge reports
  deliveredAt once every cache acknowledged.
- deploy/edge-cache: Varnish (xkey) VCL, a purge receiver and a compose
  override for the self-hosted recipe. It isn't part of the default stack.
- Optional ASSET_PREFIX for /_next/static.
compose passes EDGE_CACHE_MODE / EDGE_CACHE_TTL to frontlit-sites and
ASSET_PREFIX as a build arg; .env.example documents them with the purge
settings and the Varnish recipe's EDGE_PORT / VARNISH_SIZE.
@clbotdev
clbotdev force-pushed the perf/sites-edge-cache branch from a50b225 to 39893aa Compare October 25, 2026 08:15
@clbotdev
clbotdev changed the base branch from perf/sites-layer1-cache to main October 25, 2026 09:09
@clbotdev
clbotdev merged commit c88c2e0 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 3: optional, vendor-neutral edge cache / CDN support

2 participants