Skip to content

Research 031 — Backend edge caching ​

Status: complete

Verified 2026-08-22 against Render's docs (web-service-caching, free tier), Render Starter pricing (render.com/pricing, 2026), and Cloudflare's Cache docs (default behavior, Origin Cache Control, Vary in Cache Rules), plus the committed render.yaml and docs/architecture/deploy.md. Both [NEEDS VERIFICATION] markers (V1, V2) have verdicts below.

Human decision — no paid Render tier for now (this session) ​

The human elected to stay on Render's free tier for now. This does not refute any requirement — R7 (CDN) and R8 (no cold start) remain achievable for free — but it changes how: the clean one-line paid path (Starter tier → always-on + Render-native edge caching) is deferred, and the spec adopts the free path (a CDN fronted via Cloudflare, and keep-warm), which carries the prerequisites and caveats recorded below (F3, F4). Two sub-decisions this creates are surfaced for the human at the end.

V1 — Which CDN, and do the controls we need require a paid tier? ​

Question. Should the CDN in front of the API be Cloudflare-in-front-of-Render or Render's own edge caching, and do the controls we need (cache user-agnostic JSON, honour Cache-Control, never cache private/no-store, honour Vary) require a paid tier?

Verdict. Confirmed — both are viable. Render-native edge caching honours our headers but requires a paid tier; Cloudflare's free plan supplies every control we need but requires fronting the API with a custom domain. Given "no paid," the chosen path is Cloudflare-free-in-front (with the domain prerequisite of F3); Render-native is the simpler alternative, deferred.

Evidence.

  • Render-native edge caching is "powered by the same global CDN as Render static sites" and returns a CF-Cache-Status header (HIT/MISS/DYNAMIC/EXPIRED/BYPASS). It honours Cache-Control and CDN-Cache-Control (CDN-Cache-Control > Cache-Control; s-maxage > max-age); a custom TTL needs public + max-age/s-maxage > 0. It is opt-in (the default is None — "Disables edge caching"). Crucially: "Edge caching is not available for free web services." So Render-native = paid. (web-service-caching, free tier)
  • Cloudflare free plan supplies the controls: it "does not cache JSON by default" — you write a Cache Rule to make /api/legendaries cacheable; the origin's Cache-Control is respected by default ("Free, Pro, and Business customers have [Origin Cache Control] enabled by default and cannot disable it"), so public, max-age=… is honoured and no-store is not cached; and Vary in Cache Rules "is available on all plans (Free, Pro, Business, and Enterprise)" as of 2026-07-02, so Vary: Origin (R9) is honourable on free. Cache Rules themselves are available on the free plan. (default behavior, Origin Cache Control, Vary)

Caveat. Cloudflare-in-front requires a custom domain — you cannot proxy a *.onrender.com host through your own Cloudflare zone (F3). Without a custom domain, the only free caching is browser caching (via Cache-Control), not a shared edge.

V2 — Remove the cold start: paid always-on tier, or keep-warm? ​

Question. Should the free-tier cold start be removed by a paid always-on tier or by a keep-warm ping, and what is the trade-off?

Verdict. Confirmed — a paid Starter tier removes it reliably and would also unlock V1's Render-native caching, but the human declined paid for now. Keep-warm on the free tier is feasible via an external pinger, but tight against the free instance-hour cap and unreliable under GitHub-cron jitter. Chosen path: cold-start removal is DROPPED from this spec (this session); the ~1-minute free-tier cold start is accepted for now. The evidence below stands for a future paid-tier decision.

Evidence.

  • Free tier: "Render spins down a Free web service that goes 15 minutes without receiving any inbound traffic"; spin-up "takes about one minute" (30–60 s cold start). Each workspace gets 750 free instance hours per calendar month, and exceeding them suspends all free web services until the start of the next month (no overage billing — suspension). (free tier)
  • Paid Starter: $7/month, 0.5 vCPU / 512 MB, always-on — no spin-down, no cold start (render.com/pricing, 2026).
  • Keep-warm math: the API is the only free web service consuming instance hours (the web front-end is a static site = 0 instance hours). Keeping it warm 24/7 costs ≈ 720 h (30-day month) to 744 h (31-day month) — under the 750 h cap in every month, but with only ~6 h of head-room in a 31-day month. An external pinger hitting /api/health (which is no-store, so it always reaches the origin) every ≤14 min prevents spin-down.

Caveat. 24/7 keep-warm consumes ~96–99 % of the 750 h budget → brittle: adding any second free web service, or Render counting spin-up/restart/build overhead, risks month-end suspension of all free services. GitHub Actions cron has ~5–15 min jitter and can skip runs under load; a dedicated pinger (UptimeRobot, cron-job.org) is more reliable but is a third-party dashboard, not in-repo. Keep-warm also does not help the very first request after a deploy or a suspension. The V1 CDN independently reduces cold-start exposure by serving cache hits from the edge without touching the origin.

F3 — Cloudflare-in-front requires a custom domain (and changes the API's URL) ​

Why it matters. R7's free path. The API is at *.onrender.com; Cloudflare can only proxy hosts in a zone you control. So R7-for-free needs: a domain on Cloudflare, a proxied DNS record → the Render service (added as a custom domain on Render), a Cache Rule (cache the /api/legendaries JSON, respect origin Cache-Control, honour Vary), and updates to apps/web/src/api/apiBase.ts and apps/api/src/config/cors.ts for the new origin. This is DNS/dashboard setup — like the one-time Render GitHub connection in deploy.md, not expressible in render.yaml. Decision needed (see below).

F4 — Render's edge caching default-caches 200s without Cache-Control (privacy-critical for the paid path) ​

Why it matters. R2/R3 and enable-ordering, if the paid Render-native path is ever taken. Render's edge caching is opt-in, but once enabled it default-caches a cache-eligible 200 that has no Cache-Control for 120 minutes — "cache-eligible" = GET/HEAD, a cacheable file type, no Set-Cookie, and either a permitting Cache-Control or a default-cacheable status. So per-user endpoints (/api/account, ranking) would be edge-cached for 2 h unless stamped no-store. Render's own "prevent caching" directives are no-store or private, max-age=0, no-transform; our Surface-B private, no-store satisfies this. This makes the mechanism's fail-safe default (undeclared → private, no-store) and Surface-B stamping load-bearing, not merely tidy — and it is an ordering constraint: never enable Render edge caching before the Surface-B stamping is in place. (Cloudflare's default — no JSON caching — lacks this sharp edge, but the same fail-safe protects both paths.) Strengthens R2/R3; a plan-level ordering note.

F5 — The header mechanism delivers value with no CDN at all ​

Why it matters. Under "no paid," and if the custom-domain CDN of F3 is deferred, stamping Cache-Control: public, max-age=… still enables browser caching immediately — repeat requests from the same browser skip the origin entirely — and Surface-B private, no-store is correct regardless. The CDN (V1) adds cross-user sharing on top. So the mechanism (free, in-code) is independently valuable and unblocks Specs 032/033 no matter how the CDN/keep-warm infra decisions land. This is the ponytail core of the spec.

Refuted claims ​

None. The spec's premises hold: no cache headers today (0 hits for cache-control/etag/setHeader in apps/api/src), a ~1-minute free-tier cold start (deploy.md), and repeat identical responses paying a full origin round-trip. Research narrows the how under the "no paid" constraint; no requirement is refuted, so nothing returns to step 1.

Resolved sub-decisions (human, this session) ​

  1. R7 → browser-cache only for now. The human chose to ship the header mechanism + Cache-Control stamping (free, in-code): Surface-A responses (/api/legendaries) become browser-cacheable immediately, and Specs 032/033 get the mechanism they consume. The shared edge CDN is deferred — it needs a custom domain (F3) or the paid tier, neither adopted now. The mechanism stamps headers so a later CDN caches Surface A and never Surface B without further code (F4/F5).
  2. R8 → dropped. Cold-start removal is removed from this spec's scope (this session). A committed GitHub Actions keep-warm ping was considered and rejected as too fragile (GitHub-cron jitter; the 750 h-cap head-room of V2). The ~1-minute free-tier cold start is accepted for now; removing it later is a one-line paid-tier flip or a keep-warm ping.

Graduation ​

At step 6, fold into docs/architecture/deploy.md: the "no paid for now" decision; the free caching path (Cloudflare-in-front prerequisites of F3, or browser-cache-only); the cold-start analysis (V2: keep-warm considered and dropped as too fragile, so the free-tier cold start is accepted for now); and the deferred paid Starter path (one render.yaml line, opt-in Render-native edge caching, the default-120 min-for-200 caveat of F4). This supersedes deploy.md's "free-tier trade-off" section.