Skip to content

Cache invalidation and updates

Cache-Control: max-age=86400, from the original CDN article, tells every edge node exactly how long a cached response stays valid — 24 hours, in that example. That works fine when a page's content genuinely doesn't change until the next scheduled update. It breaks down the moment something needs to change right now: a price correction, a security patch to a JavaScript bundle, a legal takedown. Waiting up to 24 hours for a TTL to expire on every one of the hundreds of PoPs that might have cached the old version isn't an option, and this is exactly the gap the earlier practical scenario in this sub-module ran into. This article covers the actual mechanisms for forcing a change through immediately, and the less blunt alternatives to reaching for one every time.

Purging: telling every PoP to forget an object right now

A purge (sometimes called an invalidation, depending on the provider) is an explicit instruction sent to the CDN telling it to discard a cached object before its TTL would naturally expire. The provider propagates that instruction out to every PoP that might be holding a copy, and each one removes or marks that object so the next request for it can't be answered from the stale copy anymore.

curl -X POST "https://api.cdn-provider.example/purge" \
  -H "Authorization: Bearer <api-token>" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/pricing"}'
{"status": "queued", "purge_id": "prg_7f3a9c2e"}

The response here matters as much as the command: purging is asynchronous. The API confirms the request was accepted, not that every PoP worldwide has already dropped the object — propagation across a global network of PoPs takes time, typically seconds to low minutes depending on the provider, and checking a purge_id's status (most providers expose an endpoint for exactly this) is the only way to confirm completion rather than assume it.

Hard purge versus soft purge

A hard purge removes the object from cache immediately and completely — the very next request for it, from any PoP, is treated as a cold miss and goes all the way to origin, following the exact fetch pipeline the introduction to this sub-module already walked through.

A soft purge takes a gentler approach: instead of deleting the cached copy outright, it marks the entry as stale while leaving it in place and servable. The next request still triggers a fresh fetch from origin, but if that origin fetch is slow or briefly fails, the PoP can fall back to serving the marked-stale copy rather than surfacing an error to the visitor. Soft purging trades a small window where a very recently purged object might still be served once more, for meaningfully better resilience against a purge that happens to coincide with an origin hiccup.

Tip

A hard purge is the right default when content is wrong in a way that must never be served again — leaked data, a broken deploy, a legal requirement. A soft purge is the better choice for routine content updates, where serving one more stale response for a few seconds is a far smaller problem than an origin error page.

Purging by URL doesn't scale to "everything about this product changed"

Purging one specific URL works cleanly when exactly one thing changed. It falls apart the moment a single underlying change affects many cached pages at once — a product's price update that needs to invalidate the product page, the category listing that shows its price, and a "related products" widget embedded on a dozen other pages, all independently cached under different URLs.

Purging each of those URLs individually means the application has to know, at the moment of the change, every single cached representation that depends on that piece of data — a dependency graph that grows and shifts as the site does, and that's easy to get wrong by simply forgetting one.

Surrogate keys (also called cache tags, depending on the provider) solve this by letting an origin attach one or more labels to a response at the time it's cached, independent of the URL:

HTTP/1.1 200 OK
Cache-Control: public, max-age=86400
Surrogate-Key: product-142 category-shoes homepage-featured

The CDN records that this cached object is associated with all three keys, then strips the header before the response actually reaches the browser — a visitor never sees Surrogate-Key at all. When product 142's price changes, the origin issues one purge request against the key product-142 instead of enumerating URLs:

curl -X POST "https://api.cdn-provider.example/purge/key" \
  -H "Authorization: Bearer <api-token>" \
  -d '{"key": "product-142"}'

Every cached object anywhere in the CDN that was tagged with product-142 — the product page, the category listing, the homepage widget, whatever else referenced it — gets purged together, in one request, regardless of how many different URLs those objects lived under. The application only has to know which keys are relevant to a given piece of data, not which URLs currently happen to reference it, and that mapping is far more stable than a list of URLs tends to be.

stale-while-revalidate: avoiding the choice between fast and fresh

Purging solves "I need this specific thing gone now." It doesn't solve a subtler, more constant problem: a normal, non-emergency TTL expiration also means the very next visitor after expiry pays the full origin round-trip cost — the exact cache-miss pipeline from the introduction to this sub-module — just to fetch what's very likely to be an almost-identical response to the one that just expired.

The stale-while-revalidate directive, defined in RFC 5861, gives a PoP a specific instruction for this exact moment:

Cache-Control: max-age=600, stale-while-revalidate=30

For the first 600 seconds, the response is fresh and served straight from cache, same as always. For the following 30 seconds after that, the cached copy is technically stale — but rather than blocking that visitor's request on a fresh origin fetch, the PoP does something more useful: it immediately serves the stale (but only slightly stale) copy to the visitor, and in the background, without making anyone wait, fetches a fresh copy from origin to replace it. The visitor who happens to land in that 30-second window gets a fast response using content that's at most 30 seconds out of date, and the cache is fully refreshed for everyone after them, without a single visitor experiencing the full origin round-trip.

sequenceDiagram
    participant V as Visitor
    participant P as PoP
    participant O as Origin
    Note over P: max-age has expired, within stale-while-revalidate window
    V->>P: GET /article
    P-->>V: 200 OK (stale copy, served immediately)
    P->>O: background fetch for fresh copy
    O-->>P: 200 OK (fresh copy stored)
    Note over P: Next visitor gets the fresh copy

This is a genuinely different trade-off from a hard TTL expiry: it accepts serving content that's briefly, boundedly stale in exchange for removing the origin round-trip from the visitor's critical path entirely. It's a poor fit for content where staleness has a real cost — a stock price, an inventory count showing "in stock" a few seconds after the last unit sold — and a strong fit for content where a 30-second-old copy is functionally indistinguishable from a fresh one, like a news article's body text or a blog post.

Practical scenario: a purge that fixes the bug and reintroduces it a minute later

A team discovers a bug in a cached JavaScript bundle, issues a hard purge for its URL, confirms via the provider's purge-status API that it completed everywhere, and verifies the fix is live. An hour later, monitoring shows the broken version being served again from several PoPs.

curl -sI https://cdn.example.com/app.bundle.js | grep -i "cache-control\|age"
Cache-Control: public, max-age=3600
Age: 58

The purge worked exactly as intended — every PoP fetched a fresh copy from origin immediately afterward. The problem is upstream of the CDN entirely: the origin itself is still serving the broken bundle. Purging removes a cached copy; it does nothing to fix what's actually sitting at the origin, and the very next cache miss after a purge — whether from that purge itself or from a completely unrelated TTL expiry — goes straight back to origin and faithfully caches whatever it finds there. A 3600-second max-age after that recaches the broken version for another hour, on a schedule that has nothing to do with the original incident.

The fix has to happen at the origin first: deploy the corrected bundle, confirm directly against the origin (bypassing the CDN, to be certain what's actually being served there) that it's fixed, and only then purge the CDN so every PoP is forced to pick up the corrected version instead of continuing to serve whatever they already have cached. Purging before the origin fix is complete doesn't protect against this — it just guarantees the CDN will freshly cache the broken version again, sooner rather than later.

Practice exercises

  1. A product's price, its "in stock" badge, and its shipping estimate are three independently cached fragments that all need to be invalidated together whenever the product's inventory changes. Design a surrogate-key scheme that lets one purge request invalidate all three without knowing their URLs in advance.
  2. A checkout page shows real-time cart totals and must never serve stale pricing, even by a few seconds. Explain why stale-while-revalidate is a poor fit for this specific page, using the trade-off described above.
  3. Using the practical scenario above, explain in your own words why "the purge completed successfully" and "the bug is fixed" are two separate claims that don't imply each other.

This sub-module has covered how one edge node handles a single request, how it depends on — and protects — its origin, and how a cached copy gets replaced when it goes stale or wrong. All three problems assumed a request already knew which specific server family to reach. The next article steps back from caching entirely and asks a more fundamental routing question: when a packet is addressed to one destination, several equally valid destinations, or literally everyone on a network, how does the network layer itself decide who actually receives it?

Sources