Infrastructure

Cache Invalidation After Deploy: What to Purge, What Not To

Deploy every day without stale edges or origin overload. Sort assets into immutable hash keys you never purge and mutable HTML you purge by path.

A CDN edge cache is the reason your site is fast and the reason your deploy is invisible. The same cache that serves a page from a nearby city in ten milliseconds will keep serving last week's version for an hour after you ship a fix. The naive reaction is to purge everything on every deploy, but that empties the cache and sends the next wave of traffic straight to your origin, which is exactly the load the cache existed to absorb. The workable answer is not one setting. It is a small set of rules, chosen per asset type, where most of your bytes are cached forever and never purged, and only the handful of files that actually changed get invalidated. This post lays out those rules, the cache headers that back them, and the purge calls that do the precise work.

Why edge caches serve stale content after a deploy

A CDN caches a response because you told it to, usually through a Cache-Control header with a max-age. Once an edge node has a copy, it answers from that copy until the age expires or you explicitly purge it. The edge has no idea you deployed. It does not watch your git history or your release pipeline. From its point of view, nothing happened, so it keeps serving what it has.

That gap is the whole problem. The two failure modes sit on opposite ends of one dial. Turn the dial toward long cache lifetimes and high hit rates, and a deploy can take an hour to reach users, or longer if a browser is also holding a copy. Turn it toward short lifetimes and aggressive purging, and every deploy dumps cold traffic on your origin, hit rates sag, and your latency numbers get worse the more often you ship. Neither end is where you want to live.

The way out is to stop treating the site as one cacheable thing. Different files change on different schedules, and they deserve different rules.

Split assets into immutable and mutable

Almost every file a content site serves falls into one of two buckets, and the split is what makes the rest of the strategy simple.

The first bucket is content that never changes once built. A bundled JavaScript file, a stylesheet, a font, a processed image. The bytes are fixed. If you edit the source, the build produces a different file, and you would rather serve that as a new thing than mutate the old one in place.

The second bucket is content that changes in place under a stable address. Your HTML pages are the clearest case. The URL /blog/some-post has to keep working and keep pointing at the current version of that post. When you fix a typo, the address does not change, but the bytes behind it do.

These two buckets want opposite cache rules. The table below is the whole strategy on one page, and the rest of the post explains each row.

Asset type Address style Cache-Control Deploy action
JS, CSS, fonts, built images hashed name, app.a1b2c3.js public, immutable, max-age=31536000 none, the new build is a new name
Rendered HTML pages stable path, /blog/post public, max-age=60, stale-while-revalidate=86400 purge the changed paths only
API and JSON fragments stable path public, max-age=30, stale-while-revalidate=300 purge by surrogate key
Sitemaps, feeds stable path public, max-age=300 purge on publish
User-specific responses stable path private, no-store never cached at the edge

The one-line summary: immutable content is cached for a year and never purged, and mutable content is cached briefly and purged with precision. Almost none of your traffic should require a purge, because almost all of your bytes are immutable.

Immutable assets: hash the name and never purge

The trick for the first bucket is to put a hash of the file content into its name. A build step reads the final bytes of app.js, computes a short digest, and emits app.a1b2c3d4.js. The HTML that loads it references that exact name. Change one line of source and the digest changes, so the next build emits app.e5f6g7h8.js and the HTML now points there instead.

Because the name is derived from the content, a given name can only ever mean one exact set of bytes. That lets you send the strongest cache header the web has:

Cache-Control: public, immutable, max-age=31536000

max-age=31536000 is one year. The immutable directive tells the browser not to bother revalidating on reload, because the promise is genuinely true: the bytes at this name will not change for as long as the name exists. A CDN caches the file once and serves it from the edge until it gets evicted for lack of use. Your origin sees almost no repeat traffic for it.

The payoff for deploys is that you never purge these files. A deploy does not overwrite app.a1b2c3d4.js. It ships a new file at a new name and updates the HTML references. The new name was never in any cache, so there is nothing stale to clear, and the old name keeps serving old bytes to any page still asking for them. Cache invalidation for your largest assets stops being an action you perform. It becomes a state that cannot occur, because a name never changes meaning.

Mutable HTML: short TTL and a targeted purge

The HTML pages cannot use hashed names, because their addresses have to stay stable for links, bookmarks, and search engines. So they get the opposite treatment: a short cache lifetime, plus an explicit purge of the specific paths that changed.

A short TTL alone would work, but on its own it forces a choice between freshness and origin load. stale-while-revalidate breaks that tension, and the section further down covers it. For now the header looks like this:

Cache-Control: public, max-age=60, stale-while-revalidate=86400

The max-age=60 means the edge treats the page as fresh for a minute, which soaks up bursts of traffic to a popular URL. But a minute is far too long to wait for a real deploy to show up, so you do not rely on expiry. At the end of the deploy you purge the exact paths you changed.

A purge is an authenticated call to the CDN's API that drops specific cached objects. The shape is the same across providers: you name what to drop.

curl -X POST "https://api.cdn.example/v1/zones/$ZONE/purge" \
  -H "Authorization: Bearer $CDN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"files": [
        "https://example.com/blog/some-post",
        "https://example.com/blog/some-post/"
      ]}'

The important word is targeted. You purge the pages that this deploy touched, not the whole zone. If a release changed three posts and the index page, you purge four URLs. Every other cached page stays hot, so the deploy is invisible to the rest of your traffic and your origin never notices it happened.

The catch is that you have to know which paths changed. If your build already produces a manifest, a diff of the last two build outputs gives you the changed set. If it does not, the next section offers a way to purge without enumerating URLs at all.

Purge by surrogate key, not by URL

Purging by URL breaks down when one change fans out to many pages. Edit an author's name and you might need to refresh every post by that author, the author page, and a few tag pages. Listing those URLs by hand is fragile and easy to get wrong.

Surrogate keys solve this. When the origin renders a response, it attaches a header that tags the response with one or more labels. Most CDNs read a header like Surrogate-Key or a cache tag header and index every cached object by its tags.

Surrogate-Key: post-1024 author-42 tag-infra

A post page carries its own id, its author's id, and its tags. Later, when the author changes, you purge one key and the CDN drops every object carrying it, however many there are:

curl -X POST "https://api.cdn.example/v1/zones/$ZONE/purge" \
  -H "Authorization: Bearer $CDN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tags": ["author-42"]}'

You no longer track which URLs are affected. You tag responses by what they depend on, and you purge by the dependency that changed. This keeps the purge precise even when the blast radius is wide, and it keeps the deploy step from having to reason about your URL structure. The origin, which already knows what data went into each page, is the right place to declare those dependencies.

Automate the purge at the end of the deploy

A purge that a human has to remember is a purge that gets skipped, and a skipped purge is a stale site that looks like a broken deploy. The fix is to make the purge the last step of the pipeline, run automatically, after the new version is live.

Order matters. Deploy the new origin first, confirm it is serving, then purge. If you purge before the new version is live, the edge refetches the old bytes from the origin and re-caches them, and you have accomplished nothing except a moment of extra load. Purge after, and the next request for each cleared path misses the edge, hits the fresh origin, and re-caches the new version.

# 1. deploy and wait for the new revision to receive traffic
deploy_release

# 2. compute the changed paths from the build manifest diff
CHANGED=$(diff_manifest build/prev build/current)

# 3. purge only those paths, at the very end
purge_paths "$CHANGED"

Wiring the purge into the deploy also gives you one honest place to see what got invalidated. Log the purged paths or keys. When someone asks why a page updated, or why it did not, the answer is in the deploy log rather than in someone's memory.

Serve stale while you revalidate

stale-while-revalidate is what lets you keep TTLs short without paying for it in latency. It appears in the HTML header above, and it is worth understanding on its own because it removes the last reason to fear frequent expiry.

Cache-Control: public, max-age=60, stale-while-revalidate=86400

For the first 60 seconds the edge serves the cached page as fresh. After that, for the next day, the edge is allowed to serve the stale copy immediately while it fetches a new copy from the origin in the background. The user who triggered the refresh does not wait for the origin. They get the slightly old page at edge speed, and the next visitor gets the updated one. Freshness lags by one request instead of by a full origin round trip.

This changes how a short max-age feels. Without it, a 60-second TTL means one unlucky user per minute per URL waits on the origin. With it, that user gets an instant response and the origin refill happens out of band. You get near-real-time freshness and near-zero user-facing origin latency at the same time. An explicit purge still forces an immediate refresh when you need one, so the two work together: the purge handles deploys, and stale-while-revalidate handles the slow drift between them.

The tradeoffs, and when a full purge is fine

None of this is free. Hashed immutable names mean your build pipeline has to compute digests, rewrite every reference in the HTML, and keep old files around long enough that pages still loading the previous version do not break. That is real complexity, and it lives in your build tooling forever.

Targeted purging has its own cost: you have to know what changed. Either your build emits a manifest you can diff, or your origin tags responses with surrogate keys it maintains correctly. A stale tag or a missing manifest entry means a page that quietly does not refresh, which is harder to notice than a site that is uniformly behind.

Weighed against those costs is the full purge, which needs none of it. One call clears the zone and everything is guaranteed fresh. It is the honest right answer in a few cases. A small site with low traffic can absorb the cold origin hit without anyone noticing. An emergency, like pulling a page that must not be served, is worth a blunt instrument. And a migration that genuinely touches every page has nothing to gain from precision.

The rule that holds up: make most of your bytes immutable so they never need purging, purge the mutable remainder by path or by key, and keep the full purge as the tool you reach for when the situation is small enough not to care or urgent enough not to think. Your daily deploys should touch a few paths and leave the rest of the cache exactly as warm as it was a second before you shipped.

Related posts