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.

Stale-while-revalidate and purge-on-change lifecycle A cached entry serves instantly while fresh, serves stale and revalidates in the background after the freshness window, and is purged immediately when content changes. fresh: serve from cache stale: serve + revalidate expired: fetch origin max-age window stale-while-revalidate window Content changes → purge key evicts this route immediately, any time
Freshness and serve-ability are separate windows; a purge cuts across both the moment content changes, so the cache is fast by default and correct on edit.

Step-by-step fix

  1. Split freshness from serve-ability with stale-while-revalidate. A short s-maxage keeps content genuinely fresh; a longer stale-while-revalidate lets 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=600

    For 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.

  2. 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" });
    }
  3. 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
    }
  4. 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}`] }),
      });
    }
  5. 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.

Normalising requests into one public cache key Requests with tracking params are normalised into a single public cache key, while a request carrying a session cookie bypasses the shared cache and is rendered privately at the origin. Collapse tracking variants into one key; bypass sessions /p/42?utm_source=x /p/42?gclid=y /p/42 (plain) /p/42 + session cookie Normalise drop tracking params strip cookies ยท sort One public cache key shared HIT for crawlers Bypass ยท private origin never in shared cache
Tracking-param variants fold into a single crawler-shared key, while any session-bearing request skips the shared cache so a bot never receives a personalised page.

Validation

  • Read the cache status header. curl -sI https://example.com/product/42 | grep -i 'cf-cache-status\|age\|cache-control' should show HIT with an Age below 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: private and never a HIT:

    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.

Four assertions a correct SSR cache must pass The cache status is a HIT within the window, a new title appears immediately after a purge, a session request stays private and never hits, and crawled and live versions do not drift. A cache is only correct when all four hold โœ“ Cache status shows HIT with an Age below the window โœ“ After a purge, the new title appears on the next request โœ“ A session-bearing request returns private, never a HIT โœ“ GSC crawled version and the live page show no drift
Passing all four proves the cache is fast on every hit yet never serves a crawler a stale or personalised snapshot.

Common Pitfalls

  • One long s-maxage with 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 Vary header. If you serve different HTML by Accept-Language, add it to Vary or 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.

Time to first byte: cold origin render versus edge cache responses A cold origin render costs roughly 620 milliseconds, while a stale-while-revalidate response and a fresh cache hit each return in about 25 and 15 milliseconds from the edge. Edge responses return in a fraction of a cold render's time Origin render (cold) โ‰ˆ 620 ms SWR stale-serve โ‰ˆ 25 ms Fresh cache HIT โ‰ˆ 15 ms representative time to first byte โ€” lower is better
A fresh HIT and a stale-while-revalidate serve both answer in tens of milliseconds, so no crawler ever waits out the cold origin render the cache exists to avoid.

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.

โ† Back to Edge Rendering & CDN SEO