Caching Streamed HTML with Stale-While-Revalidate

Streaming and caching are often presented as alternatives β€” either the origin renders progressively for each visitor, or the edge serves a stored copy. They compose, provided the cache tees the stream rather than accumulating it and the whole path forwards bytes. This walkthrough sets up that combination and proves it works, as the concrete configuration behind edge caching and delivery for islands.

Prerequisites

Implementation Steps

Fresh, Stale-Servable, Expired An entry passes through three states. During the freshness window it is served directly. During the stale window it is still served immediately while a background refresh runs. After both windows it must be re-fetched, and the visitor waits. The goal of tuning is that requests almost never fall into the third state. One entry, three states fresh Β· 0 to 300 s served from cache, no origin contact stale-servable Β· +60 s served instantly, refresh in the background expired the visitor waits Tuning goal: almost no request lands in the third state A route with steady traffic never reaches expiry, because a request during the stale window triggers the refresh. A route with sparse traffic will, so pair a long stale window with a scheduled warm request for pages that matter. Freshness governs correctness; the stale window governs whether anyone ever waits.

Step 1 β€” Set the two windows deliberately

cache-control: public, s-maxage=300, stale-while-revalidate=60

s-maxage comes from the tightest shared value on the page β€” the freshness decision made in choosing between static and server rendering per route. The stale window comes from the origin’s worst-case render time with headroom. Note the absence of max-age: browsers holding their own copy for five minutes would make a content fix invisible to anyone who has recently visited, and that is rarely what you want for a document.

Step 2 β€” Confirm the platform tees rather than buffers

// Edge worker: write to the cache and stream to the visitor from one response.
const res = await fetch(request);
const [toVisitor, toCache] = res.body.tee();     // two readable streams, one source
ctx.waitUntil(cache.put(cacheKey, new Response(toCache, res)));
return new Response(toVisitor, res);             // visitor still gets it progressively

The tee() is the whole trick. Reading the body to completion before storing it would work and would delay the visitor by the full render time, which is exactly the behaviour that makes people believe caching and streaming are incompatible.

Step 3 β€” Coalesce concurrent misses

Enable request collapsing so that when an entry expires, one origin request serves every waiting visitor. Without it a popular route produces a burst at every expiry, and the burst is largest exactly when traffic is highest.

Step 4 β€” Remove buffering from the path

Disable response buffering for HTML on every proxy in front of the origin. This is usually one directive, in a layer that a front-end team does not own, which is why it is the most frequently missed step in the entire configuration.

Verification

What the Arrival Timeline Should Look Like On a cache hit the whole document arrives at once from the edge, in a few milliseconds. On a stale hit the same thing happens while a refresh runs behind it. On a true miss the shell arrives early and the slow fragment later, which proves streaming survived the path. If the miss case shows everything arriving together, something is buffering. Three request outcomes, three timelines cache hit complete document, 8 ms stale hit complete document, 8 ms β€” refresh runs behind it true miss shell at 60 ms fragment at 900 ms β€” streaming intact If the third row shows one arrival at 900 ms, a proxy is buffering and the streaming work is being wasted.
# Byte-level arrival through the real chain. Timestamps are the evidence.
curl --no-buffer -s https://example.test/product/42 | ts '%.s' | head -5

# Cache state across three requests: expect MISS then HIT then HIT.
for i in 1 2 3; do
  curl -s -o /dev/null -D - https://example.test/product/42 | grep -iE 'cf-cache-status|x-cache|age:'
done

# Stale serving: wait past s-maxage, then confirm the response is still fast
# and that the age header exceeds the freshness window on that request.
sleep 310 && curl -s -o /dev/null -w '%{time_starttransfer}
' -D /tmp/h https://example.test/product/42 && grep -i age: /tmp/h

Three assertions come out of those commands. The shell must arrive well before the fragment on a miss. The second and third requests must report a hit. And the request made after expiry must still be fast, with an age above the freshness window β€” that combination is the signature of stale serving working. If it is slow instead, either the stale directive is not reaching the cache or the platform ignores it.

Operating It Afterwards

Two numbers keep this healthy over time, and both belong on a dashboard rather than in someone’s memory.

Age at delivery. Chart the distribution of the age header on hits. A distribution clustered near zero means most visitors get a nearly fresh copy. A distribution pressed against the maximum means the window is longer than the content actually needs, and shortening it costs almost nothing because refreshes happen in the background anyway.

Origin request rate per route. Under stale-while-revalidate this should be roughly one request per freshness window per popular route, plus a slow trickle for rarely requested ones. If it tracks traffic instead, something is bypassing the cache β€” usually a fragmenting key, as covered in splitting cache keys for personalised islands.

The one operational surprise worth planning for is a deploy that changes rendered output. Until the entries are purged or expire, visitors receive the previous version, and a stale window extends that by its own duration. For content changes this is the intended behaviour. For a broken release it is an extra minute of exposure, which argues for a targeted purge as part of the rollback procedure rather than waiting for expiry.

Two Numbers to Chart Afterwards Age at delivery shows how stale the typical hit is; a distribution pressed against the maximum means the window is longer than the content needs. Origin request rate should track the freshness window rather than traffic. What to watch once it is live age at delivery β€” the distribution, not the mean clustered near zero is healthy; pressed to the maximum means the window is too long origin request rate per route should track the freshness window; if it tracks traffic, something bypasses the cache both belong on a dashboard, not in someone memory

Troubleshooting

Does caching a streamed response destroy the streaming?

Not on a platform that tees the response: the bytes are written to the cache and forwarded to the requester at the same time, so the first visitor still gets progressive delivery and later visitors get the complete document instantly from cache. It does destroy streaming on a proxy that accumulates the whole body before storing and forwarding, which is why the arrival-time measurement matters more than any configuration file.

What stale window should I use?

Shorter than the freshness window, and long enough to cover a slow origin render. If the origin takes up to two seconds under load, a stale window of thirty to sixty seconds means the refresh has ample time to complete in the background while visitors keep receiving the previous copy. A stale window longer than the freshness window is not wrong, but it means the typical served copy is stale rather than fresh, which is rarely the intent.

How do I stop a hundred simultaneous misses hitting the origin?

Request coalescing at the cache layer: the first miss goes to the origin, the rest wait on that in-flight request and receive the same response. Most edge platforms provide it under a name like request collapsing and it is off by default surprisingly often. Without it, an expiring entry on a popular route produces a burst proportional to traffic, which is exactly the load the cache exists to prevent.

← Back to Edge Caching and Delivery for Islands