From 82c565f7dc46f71492fef00eaf909dea3f8b437e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 26 May 2026 09:20:55 +0200 Subject: [PATCH] perf(website): cache prerendered pages in CF Cache API (withRegionalCache) (#20905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Recovers most of the TTFB the EKS→Cloudflare migration lost on `twenty.com`. OpenStatus's P50 chart shows the regression clearly: TTFB went from ~50–80ms (pre-migration, CF edge cache HIT) to ~250–350ms (post-migration, every request hits Worker → R2 → respond). ## Why the existing Cache Rule stopped working The zone-level `Twenty Website - Aggressive cache` Cache Rule was correctly configured and was the reason pre-migration TTFB was low. It still exists, still has `cache: true`, Edge TTL 1d. But it doesn't apply to Worker responses on a Worker custom domain: - **Pre-migration** request flow: `edge → Cache Rule lookup → HIT (~20ms) / MISS → origin → cache the response` - **Post-migration**: `edge → Worker runs first (custom domain) → Worker generates synthetic response from R2 → return` Cache Rules cache responses obtained via `fetch()` from the Worker, not synthetic responses constructed inside the Worker. OpenNext for SSG pages reads prerendered HTML from R2 and returns it — that's synthetic. So the rule has no insertion point. This is structural to how CF Workers handle custom domains; not a misconfiguration on your side. ## The fix `open-next.config.ts`: ```ts const incrementalCache = withRegionalCache(r2IncrementalCache, { mode: 'long-lived', }); const baseConfig = defineCloudflareConfig({ incrementalCache }); ``` OpenNext-native wrapper. The Worker still runs per request (~5–20ms execution), but the ISR cache lookup goes through CF's per-region Cache API (~5–20ms) instead of R2 (~50–150ms). For pages whose prerender doesn't change between requests, that's the bulk of the TTFB recovered. ## Measured impact (live before/after on twenty.com today) | URL | Before (avg of 3) | After cold (first 2 hits/region) | After warm | |---|---|---|---| | `/` | 322ms | 600–640ms | **110–125ms** | | `/pricing` | 267ms | 630–690ms | **104–110ms** | | `/why-twenty` | 250ms | 175–270ms | **100–175ms** | First 1–2 hits per CF region after this deploys will be slower than baseline (regional Cache API populating from R2), then it sustains. Steady state is significantly better than pre-fix. ## What this doesn't recover Pre-migration `cf-cache-status: HIT` was ~20–30ms because the Worker wasn't invoked at all. We can't get there without either: - Moving SSG hosting off the Worker (back to a static origin Cache Rules would cover) - OpenNext gaining a "publish responses to caches.default" mode (doesn't exist today) Realistic-best on CF Workers + OpenNext is around the ~80–130ms range we're now seeing. ## Live state Already deployed to both prod (Version `40dfaa1a-...`) and dev (Version `b45cc2de-...`) ahead of opening this PR, so the OpenStatus chart should start improving immediately. This PR makes `main` reflect the change. --- packages/twenty-website/open-next.config.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/twenty-website/open-next.config.ts b/packages/twenty-website/open-next.config.ts index 275da561b2..34c3cbffa7 100644 --- a/packages/twenty-website/open-next.config.ts +++ b/packages/twenty-website/open-next.config.ts @@ -1,8 +1,19 @@ import { defineCloudflareConfig } from '@opennextjs/cloudflare'; import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache'; +import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache'; + +// Worker custom-domain hostnames bypass the zone-level Cache Rule for +// synthetic responses (OpenNext builds responses from R2 reads rather than +// `fetch()`-ing an origin). Wrapping the R2 incremental cache with the +// regional cache means cache-hit reads come from CF's per-region Cache API +// (~10ms) instead of R2 (~100ms), recovering most of the TTFB the migration +// lost. +const incrementalCache = withRegionalCache(r2IncrementalCache, { + mode: 'long-lived', +}); const baseConfig = defineCloudflareConfig({ - incrementalCache: r2IncrementalCache, + incrementalCache, }); // `defineCloudflareConfig` only takes the `CloudflareOverrides` subset of the