Caching SSR HTML at the CDN Without Serving Stale Pages
Server rendering gives a crawler full HTML, but rendering every request at the origin is slow and expensive at scale. Caching the rendered HTML at the CDN fixes both โ provided the cache never serves a snapshot older than the content it represents. This guide belongs to edge rendering and CDN SEO and covers the three controls that make a cache fast and correct: stale-while-revalidate, per-route cache keys, and purge-on-change.
Problem Statement
The tempting shortcut after adopting SSR is a long shared cache: set s-maxage high, and the origin barely renders. But a crawler that fetches a cached page gets whatever was rendered when the entry was written. If a product price changed, an article title was edited, or a canonical tag was corrected an hour ago, the cache still serves the old bytes โ and that old snapshot is what gets indexed. The stale index then disagrees with what users see, which is exactly the mismatch that erodes trust in a page.
The goal is a cache that returns a fast response on every hit yet is never meaningfully out of date. That requires separating how long a response may be served from how long it is considered fresh, and having an explicit way to evict a specific page the instant its content changes.
Step-by-step fix
-
Split freshness from serve-ability with
stale-while-revalidate. A shorts-maxagekeeps content genuinely fresh; a longerstale-while-revalidatelets the CDN serve the last good copy instantly while it refreshes in the background. No crawler ever waits for a cold origin render.Cache-Control: public, s-maxage=60, stale-while-revalidate=600For 60 seconds the response is served as fresh. For the next 10 minutes it is served instantly and revalidated behind the scenes, so the next request gets fresh bytes without any request paying the render cost.
-
Set a per-route cache key that excludes personalisation. Cache only the anonymous, crawler-equivalent response and vary on the axes that actually change the HTML. Never key on a session cookie.
// Cloudflare Worker: build a deterministic public cache key function cacheKeyFor(request: Request): Request { const url = new URL(request.url); // Drop tracking params so ?utm_source variants share one entry for (const p of ["utm_source", "utm_medium", "gclid", "fbclid"]) { url.searchParams.delete(p); } url.searchParams.sort(); // Public cache only: strip cookies from the key return new Request(url.toString(), { method: "GET" }); } -
Bypass the shared cache for authenticated requests. If a request carries a session, render at the origin and mark it private so it is never stored in the shared cache a crawler reads.
if (request.headers.get("cookie")?.includes("session=")) { const res = await renderAtOrigin(request); res.headers.set("Cache-Control", "private, no-store"); return res; // never enters the shared/crawler cache } -
Purge the exact key on content change. Wire your CMS or deploy hook to evict the specific route when its content is edited or published, so the next crawl fetches fresh HTML rather than waiting out the freshness window.
// Called from a CMS webhook after a product or article is updated async function purgeRoute(path: string, env: Env): Promise<void> { await fetch(`https://api.cloudflare.com/client/v4/zones/${env.ZONE_ID}/purge_cache`, { method: "POST", headers: { Authorization: `Bearer ${env.CF_API_TOKEN}`, "content-type": "application/json", }, body: JSON.stringify({ files: [`https://example.com${path}`] }), }); } -
Emit accurate
Last-Modified/ETag. Conditional revalidation lets the CDN and crawlers skip re-downloading unchanged pages, which protects crawl budget on large sites.
Steps 2 and 3 both hinge on the cache key. Tracking-param and session variants must collapse to one public entry a crawler can share, while any request that carries a session has to skip the shared cache entirely โ the flow below shows both paths.
Validation
-
Read the cache status header.
curl -sI https://example.com/product/42 | grep -i 'cf-cache-status\|age\|cache-control'should showHITwith anAgebelow your combined window. -
Confirm freshness after an edit. Change a title, trigger the purge, then
curl -s https://example.com/product/42 | grep -o '<title>[^<]*</title>'โ the new title must appear on the very next request. -
Prove personalisation bypasses the cache. A request with a session cookie should return
Cache-Control: privateand never aHIT:console.assert( !cachedResponse.headers.get("cache-control")?.includes("private"), "A private response must never be stored in the shared cache", ); -
Diff crawled vs live. In GSC URL Inspection compare the indexed version to a live test; a persistent gap points at a stale entry with no purge path. This is part of the broader audit workflow.
Common Pitfalls
- One long
s-maxagewith no purge. The cache is fast but indexes stale prices and titles. Always pair a cache with an eviction trigger. - Keying on cookies or tracking params. Session-keyed caches fragment into thousands of near-identical entries or, worse, leak a personalised page to a crawler. Normalise the key.
- Caching error responses. A transient 500 rendered into the cache gets served to crawlers as a broken page. Only cache 200 responses and set a short negative TTL for errors.
- Forgetting the
Varyheader. If you serve different HTML byAccept-Language, add it toVaryor the cache mixes locales. - Purging the whole zone on every edit. A full purge stampedes the origin. Purge the specific route keys instead.
The reason the pitfalls above are worth avoiding is the latency payoff. Serving from the edge โ whether a fresh HIT or a stale-while-revalidate response โ returns bytes in a fraction of the time a cold origin render costs, as the representative figures below illustrate.
Frequently Asked Questions
Can caching SSR HTML cause Google to index stale content?
Yes, if the cache has no invalidation path. When a shared cache holds a rendered page longer than the content stays accurate, a crawler can index an old price, title, or canonical. The fix is to pair a short freshness window with stale-while-revalidate and to purge the specific cache key whenever the underlying content changes.
What is stale-while-revalidate and why does it help SEO?
Stale-while-revalidate lets the CDN serve a slightly stale cached response instantly while it refreshes the entry in the background. Crawlers get a fast first byte on every hit, and the cache converges on fresh content within one revalidation cycle, so no request pays the full origin render latency.
Should logged-in and logged-out users share the same cached HTML?
No. Only cache the anonymous, crawler-equivalent response. Vary the cache key so authenticated or personalised requests bypass the shared cache, otherwise a crawler could receive a user-specific page or a user could receive someone elseโs cached content. Cache the public version that a bot would see and let personalised requests hit the origin.
Related
- Edge Rendering & CDN SEO โ the section overview and where caching sits among edge approaches.
- Edge-Side Rendering with Cloudflare Workers โ what produces the HTML this cache stores.
- Canonical URL Management in SPAs โ the tag a stale cache most often gets wrong.
- JavaScript Execution Limits and Crawl Budget โ why conditional revalidation protects crawl throughput.
โ Back to Edge Rendering & CDN SEO