> [!FOUNDER]
> "The most expensive caching bug I have seen was not a slow site. It was a fast site confidently serving last week’s price to ten thousand buyers, because the cache was configured for speed and nobody owned invalidation. Caching is easy to add and easy to get half right. The half that matters is not how long you cache. It is how precisely you can uncache, and how honestly you bound the staleness you accept in between." — Enow A. Jovial, Founder & Chief Executive Officer
Freshness is a budget, not a boolean
Caching conversations start with a false binary: fresh versus stale, as if a response were either current or wrong. HTTP caching does not work that way. A response has a freshness lifetime, set by the origin, after which it is stale. Stale does not mean unusable: it means the cache may no longer serve it without revalidating, unless the origin has explicitly allowed stale service for a bounded time in defined circumstances. That bounded permission is this article's architecture, and it comes from RFC 5861, published in May 2010 as an Informational RFC by Mark Nottingham: stale-while-revalidate and stale-if-error.
!RFC 5861, the IETF document that defines the two Cache-Control extensions for stale content
The RFC's framing is the design in one sentence: stale-while-revalidate hides latency by answering immediately while revalidating in the background; stale-if-error improves availability by answering with a stale response when revalidation hits an error. One buys speed, the other buys uptime, and both charge bounded staleness in seconds, chosen by the origin.
The two directives, stated exactly
Stale-while-revalidate is a response directive with a value in delta-seconds: the cache may serve the response after it becomes stale, up to the indicated number of seconds, and when it does, it should attempt revalidation while still serving stale responses, without blocking. The RFC's canonical example is the whole directive in one line:
Cache-Control: max-age=600, stale-while-revalidate=30
That response is fresh for 600 seconds, and may continue to be served stale for up to 30 more seconds while an asynchronous validation is attempted. If validation is inconclusive, or no traffic triggers it, after 30 seconds the function ceases to operate and the next request blocks and is handled normally. The RFC's design rule: set max-age and stale-while-revalidate to the longest total lifetime you can tolerate. With both at 600, the server must tolerate the response being served from cache for up to 20 minutes. The budget is the sum, not the first number.
$
ext{Maximum Staleness} = ext{max-age} + ext{stale-while-revalidate}
$
$
ext{Total Tolerated Age (RFC example)} = 600 + 600 = 1{,}200 ext{seconds} = 20 ext{minutes}
$
Stale-if-error is the sibling directive, also in delta-seconds: when an error is encountered, a cached stale response may be used to satisfy the request, regardless of other freshness information. The RFC's example is equally precise:
Cache-Control: max-age=600, stale-if-error=1200
That response is fresh for 600 seconds, and may be used if an error is encountered after becoming stale for an additional 1,200 seconds. At 900 seconds, a failed revalidation returns the stale success instead of the 500. After the age passes 1,800 seconds, the cache must pass the error through. Here an error means any situation producing a 500, 502, 503, or 504 response. A stale response served under either directive should still be visibly stale, with a non-zero Age header.
> [!KEY TAKEAWAY]
> Set the sum, not the first number. Your staleness budget is max-age plus stale-while-revalidate; your outage budget is max-age plus stale-if-error. If the business cannot tolerate a 20-minute-old price, do not ship a header adding up to 20 minutes.
The window that only opens when traffic arrives
Stale-while-revalidate has a subtlety that invalidates naive capacity planning: validation only happens if a request arrives after staleness but before the window ends. The RFC states this plainly. A low-traffic URL with a 30-second window may never get a request inside it, so its first visitor after the window pays the full revalidation cost. A high-traffic URL revalidates constantly, so the window behaves like continuous freshness.
The practical consequence: stale-while-revalidate is a tail-latency tool for hot paths, not a freshness tool for cold ones. For cold paths, use longer max-age with explicit purging on change, or a background warmer that requests the URL on a schedule. A short window on a page that gets three visits a day will almost always expire unused, and the page will behave exactly as if the directive were absent. Set the window wider than the typical gap between requests for that URL class, or do not expect the directive to do anything.
Origin offload, in numbers
Caching decisions should be argued in arithmetic. The metric that matters is the origin request rate after caching: total requests multiplied by one minus the cache hit ratio.
$
ext{Origin Requests} = ext{Total Requests} imes (1 - ext{Cache Hit Ratio})
$
Worked example: a product listing page receives 1,000,000 requests per day. At a 95% edge hit ratio, the origin answers 50,000 requests. At 99%, it answers 10,000. Four points of hit ratio are worth 40,000 origin requests per day: the difference between a small origin fleet and a large one. Now add stale-while-revalidate to the same page with a 60-second freshness lifetime and a 300-second revalidation window. Requests inside the window are served instantly from the edge while one background revalidation refreshes the entry, so most visitors see edge latency. Origin load is governed by distinct URLs times edge nodes divided by the window, not by visitor count. That decoupling is the economic case for edge caching, and it is why the hit ratio, not the header, belongs on the dashboard.
$
ext{Daily Origin Requests at 95% Hit Ratio} = 1{,}000{,}000 imes (1 - 0.95) = 50{,}000
$
$
ext{Daily Origin Requests at 99% Hit Ratio} = 1{,}000{,}000 imes (1 - 0.99) = 10{,}000
$
Purge by tag: invalidation by construction
Freshness windows bound staleness; purge removes it on demand. The question is what unit you purge, and the wrong unit is the URL. A single product change touches the product page, every category page that lists it, every search result, the homepage module, and any feed. Purging by URL requires enumerating that list, and enumeration fails silently: the URLs you forgot stay stale, and nothing tells you.
Purge by tag inverts the problem. The origin attaches a Cache-Tag header to each response, naming every entity the response depends on: product, category, brand. When the product changes, one purge call naming the product tag invalidates every response carrying it. Cloudflare's cache documentation describes the workflow exactly: add tags in the Cache-Tag header, proxy traffic through Cloudflare, and purge by tag from the dashboard or API, after which purged content returns a cache MISS. The tag header is stripped before delivery to the visitor.
Tag mechanics have limits that belong in the design, not in a post-incident review. Tags must be printable ASCII with no spaces, matching is case-insensitive, and a single API call accepts tags up to 1,024 characters each. The aggregate Cache-Tag header cannot exceed 16 KB, roughly 1,000 unique tags per response, and the dashboard accepts up to 100 tags per purge. Invalid tags are silently dropped at storage time, so a malformed tag does not error: the response is cached with the remaining valid tags, and a later purge against the malformed name matches nothing. Test tag generation by purging a tag and observing a MISS.
Workers Caching adds a second purge vocabulary with its own scoping rule. A Worker can delete its own cached responses with purge, or mark them stale with invalidate, so the cache revalidates them with the Worker on the next request instead of deleting them. Purge accepts tags, path prefixes, or purgeEverything; tags and path prefixes in one call are unioned. The trap is scoping: purge and invalidate are scoped to the entrypoint that called them, and no zone-level purge, from dashboard, API, or Terraform, affects Workers Caching content. A tag applied in two entrypoints is not purged by one call from one entrypoint; centralize purge calls in a shared entrypoint, or call purge from each. Hierarchical soft tags handle path-shaped invalidation: tag a response at /blog/2025/02/hello with _path:/blog/, _path:/blog/2025/, _path:/blog/2025/02/, and _path:/blog/2025/02/hello, and purging _path:/blog/2025/ removes the whole month.
| Purge method | What it removes | When to use it | Authority |
| :--- | :--- | :--- | :--- |
| By single URL | Exactly the listed URLs | One-off corrections | Cloudflare API purge documentation |
| By tag | Every response carrying the tag | Entity changes | Cloudflare purge-by-tags documentation |
| By prefix | Every URL under the path prefix | Section redesigns | Cloudflare API purge documentation |
| By hostname | All content for the hostname | Domain migration | Cloudflare API purge documentation |
| By Worker purge() | The calling entrypoint's cache entries | Worker-owned responses | Cloudflare Workers purge documentation |
| By Worker invalidate() | Nothing deleted; entries marked stale | Fresh check cheaper than cold fetch | Cloudflare Workers purge documentation |
| Purge everything | The whole zone, or the whole entrypoint | Corruption, key changes, emergencies | Cloudflare API and Workers purge documentation |
> [!WARNING]
> Purge everything is not a routine tool. It converts every cached response into a simultaneous origin fetch: the thundering herd the cache existed to prevent. Reserve it for cache corruption and key-schema changes, in a low-traffic window, with the origin scaled for the stampede. A team that purges everything after every deploy defeats its own cache daily.
The failure modes, named before they happen
Stale prices and inventory. The classic edge failure is a price, stock count, or availability flag served past its truth because the response carried a long freshness window and no tag purge on write. The fix is architectural: any response depending on mutable business data must carry the tags of the entities it depends on, and every write path for those entities must purge those tags. A write path without a purge call will eventually serve a lie. Checkout, cart, account, and payment responses should not be cached publicly at the edge at all.
Stale-if-error masking a dead origin. A long stale-if-error window keeps a site up during an outage, and hides the outage from every monitor that only checks for 200 responses. The site looks alive while serving hours-old content, and the team learns about the failure from a customer complaint. Pair stale-if-error with origin health alerting that does not depend on edge responses: alert on origin error rates and on the Age header, not just on status codes.
Vary and personalization leaks. A response cached without regard to the headers that change it, such as Accept-Language, device class, or authorization, will serve one user's variant to another. The cache key must include every dimension the response varies on, and authenticated responses must never share a public cache entry with anonymous ones. This failure is a privacy incident wearing a performance costume.
Invalid tags failing silently. A malformed tag is dropped at storage time without an error, so tag generation code that interpolates user input, Unicode, or spaces produces tags that never attach, and purges against them succeed vacuously while content stays stale. Sanitize tags at generation, assert count and character set in tests, and verify purges by observing MISS.
Purge accepted is not purge confirmed. The API documentation is explicit: a 200 response with success true means the purge was accepted, not that content was cached or removed. Verification is a request to a purged URL confirming CF-Cache-Status MISS. Build that check into the deploy pipeline and the incident runbook. An unverified purge is a hope, and hopes do not invalidate caches.
Operator boundaries: where each tool stops
Stale-while-revalidate stops at the window edge: it does not refresh content nobody requests, and it does not help cold URLs. Stale-if-error stops at its window and at non-error responses: a 404 from the origin is not an error for this purpose. Purge by tag stops at the tag set the origin actually attached; it cannot invalidate a response that was never tagged. Zone-level purge stops at the Workers Caching boundary. Each tool has a precise jurisdiction, and the design must place every response inside a jurisdiction whose invalidation path is owned by the code that mutates the data.
The deployment protocol, in order
1. Classify responses. Static assets, public content, personalized content, and transactional content each get a cache policy. Only the first two belong in a shared edge cache.
2. Set the freshness budget from the business, not from habit. Max-age plus stale-while-revalidate must be less than the oldest content the business tolerates serving, per response class. Prices get short budgets and tag purges; marketing pages get long budgets and tag purges; assets get immutable budgets with content-hashed filenames.
3. Tag every cacheable response by its dependencies. Product, category, author, and collection tags at minimum, plus hierarchical path tags.
4. Wire purge to every write path. The handler that mutates an entity purges its tags in the same request flow, after the write commits. A write path without a purge call is an open defect.
5. Verify purges by observation. After deploy or an incident purge, request purged URLs and confirm CF-Cache-Status MISS before declaring it resolved.
6. Alert on Age and origin errors, not just status codes. A 200 with an Age of hours during an outage is an incident wearing a success costume.
Execution checklist
> [!TIP]
> Ship tag purging before you ship long freshness windows. A cache with a 24-hour max-age and a working tag purge is safer than a cache with a 60-second max-age and no purge at all: the first is correct by construction on every write, while the second is wrong for up to 60 seconds on every write and still hammers the origin. Invalidation first, aggression second.
Related reading on this site: the Cloudflare Workers versus Vercel edge functions guide, for the compute layer this caching layer sits in front of; the Vercel versus Dokku on Hetzner infrastructure guide, for the origin economics edge offload protects; and the decoupled web apps and edge prerendering guide, for the rendering architecture that makes edge caching pay. To budget the origin fleet the cache protects, run the numbers through the website cost calculator. For the retrieval layer behind the edge, see the vector database architecture comparison, for how Qdrant, Pinecone, and pgvector handle filtered search differently. For what that cache actually costs per gigabyte on each vendor, see the Cloudflare vs Fastly vs AWS CloudFront CDN pricing comparison.