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
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
# 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.
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.
Related
- Splitting Cache Keys for Personalised Islands β keeping the document shared while one region is not.
- Edge Caching and Delivery for Islands β policies for documents, chunks and fragments.
- Next.js App Router Streaming Patterns β producing the streamed response this caches.
β Back to Edge Caching and Delivery for Islands