StackPractices
intermediate By Mathias Paulenko

CDN Cache Invalidation Strategies and Patterns

Implement CDN cache invalidation using purge APIs, surrogate keys, tag-based invalidation, and versioned URLs to keep content fresh

CDN caching reduces latency and origin load, but cached content can become stale. Invalidation tells the CDN to fetch a fresh copy from the origin. This approach handles four invalidation strategies — URL purge, surrogate key (tag-based) invalidation, versioned URLs, and soft purge — with code examples for Cloudflare and Fastly.

When to Use This

  • Content updates that must appear immediately (news, pricing, product inventory)
  • Deployments where old assets shouldn’t persist at the edge
  • Multi-page sites where updating one page should invalidate related pages (a product change that also affects its category page and search results)
  • CMS- or webhook-driven workflows where the origin knows exactly what changed and can trigger a targeted purge
  • High-traffic pages where a hard purge would slam the origin with simultaneous cache misses — use soft purge instead

You can skip invalidation entirely if your static assets already use content-hashed filenames and your HTML responses carry short TTLs — the URL changes itself, so there’s nothing to purge. If you’re caching API responses in Redis rather than at the edge, that’s a different layer — see Node.js Caching with Redis: Cache-Aside and TTL Patterns.

Prerequisites

  • A CDN provider (Cloudflare, Fastly, AWS CloudFront, or similar)
  • API credentials for the CDN

Solution

1. URL Purge — Invalidate Specific URLs

Cloudflare:

import httpx

CLOUDFLARE_API = "https://api.cloudflare.com/client/v4"

async def purge_cloudflare_urls(zone_id: str, api_token: str, urls: list[str]) -> dict:
    """Purge specific URLs from Cloudflare's cache.

    Args:
        zone_id: Cloudflare zone ID.
        api_token: API token with purge permission.
        urls: List of URLs to purge.

    Returns:
        API response dict.
    """
    async with httpx.AsyncClient() as client:
        response = await client.post(
            f"{CLOUDFLARE_API}/zones/{zone_id}/purge_cache",
            headers={
                "Authorization": f"Bearer {api_token}",
                "Content-Type": "application/json",
            },
            json={"files": urls},
        )
        return response.json()

# Usage
await purge_cloudflare_urls(
    zone_id="abc123",
    api_token="token",
    urls=["https://example.com/page1", "https://example.com/page2"],
)

Fastly:

FASTLY_API = "https://api.fastly.com"

async def purge_fastly_urls(service_id: str, api_key: str, urls: list[str]) -> list[dict]:
    """Purge specific URLs from Fastly's cache."""
    results = []
    async with httpx.AsyncClient() as client:
        for url in urls:
            response = await client.post(
                f"{FASTLY_API}/purge/{url}",
                headers={"Fastly-Key": api_key},
            )
            results.append(response.json())
    return results

2. Surrogate Key Invalidation — Tag-Based Purge

Surrogate keys let you tag related URLs and invalidate them with one API call. This is ideal when updating a product should invalidate its product page, category page, and search results.

Fastly (native surrogate keys):

# In your origin response headers:
# Surrogate-Key: product-123 category-456

async def purge_fastly_key(service_id: str, api_key: str, surrogate_key: str) -> dict:
    """Purge all URLs tagged with a surrogate key."""
    async with httpx.AsyncClient() as client:
        response = await client.post(
            f"{FASTLY_API}/service/{service_id}/purge/{surrogate_key}",
            headers={"Fastly-Key": api_key},
        )
        return response.json()

# Usage — purge all pages related to product-123
await purge_fastly_key("svc123", "key", "product-123")

Cloudflare (Cache Tags):

# In your origin response headers:
# Cache-Tag: product-123, category-456

async def purge_cloudflare_tags(zone_id: str, api_token: str, tags: list[str]) -> dict:
    """Purge all URLs with matching cache tags."""
    async with httpx.AsyncClient() as client:
        response = await client.post(
            f"{CLOUDFLARE_API}/zones/{zone_id}/purge_cache",
            headers={
                "Authorization": f"Bearer {api_token}",
                "Content-Type": "application/json",
            },
            json={"tags": tags},
        )
        return response.json()

3. Versioned URLs — Content-Hash Invalidation

Instead of purging, change the URL when content changes. This eliminates invalidation entirely:

// Build step — generate content-hashed asset URLs
import crypto from "crypto";
import fs from "fs";

function generateVersionedAsset(filePath: string): string {
  const content = fs.readFileSync(filePath);
  const hash = crypto.createHash("sha256").update(content).digest("hex").slice(0, 8);
  const ext = filePath.split(".").pop();
  const base = filePath.replace(/\.\w+$/, "");
  return `${base}.${hash}.${ext}`;
}

// app.js → app.a1b2c3d4.js
// When content changes, the hash changes, and the CDN fetches the new URL
<!-- HTML references the hashed filename -->
<script src="/assets/app.a1b2c3d4.js"></script>
<link rel="stylesheet" href="/assets/styles.e5f6g7h8.css" />

4. Soft Purge — Graceful Invalidation

Soft purge marks cached content as stale instead of deleting it. The CDN serves the stale content while fetching a fresh copy in the background, avoiding origin spikes.

Fastly Soft Purge:

async def soft_purge_fastly(service_id: str, api_key: str, surrogate_key: str) -> dict:
    """Soft purge — mark as stale, serve while refreshing."""
    async with httpx.AsyncClient() as client:
        response = await client.request(
            "POST",
            f"{FASTLY_API}/service/{service_id}/purge/{surrogate_key}",
            headers={
                "Fastly-Key": api_key,
                "Fastly-Soft-Purge": "1",
                "Fastly-Soft-Purge-TTL": "30",
            },
        )
        return response.json()

5. Automated Invalidation on Deploy

import os

async def invalidate_on_deploy():
    """Purge CDN cache after a deployment."""
    cdn = os.getenv("CDN_PROVIDER")
    service_id = os.getenv("CDN_SERVICE_ID")
    api_key = os.getenv("CDN_API_KEY")

    if cdn == "fastly":
        # Purge everything via surrogate key "all"
        await purge_fastly_key(service_id, api_key, "all")
    elif cdn == "cloudflare":
        zone_id = os.getenv("CLOUDFLARE_ZONE_ID")
        api_token = os.getenv("CLOUDFLARE_API_TOKEN")
        # Purge everything
        async with httpx.AsyncClient() as client:
            await client.post(
                f"{CLOUDFLARE_API}/zones/{zone_id}/purge_cache",
                headers={"Authorization": f"Bearer {api_token}"},
                json={"purge_everything": True},
            )

    print("CDN cache invalidated after deploy")

How It Works

  1. URL purge sends an API call to the CDN to delete specific URLs from edge cache, so the next request misses and pulls a fresh copy from the origin.
  2. Surrogate keys (Fastly) and cache tags (Cloudflare) group URLs by tag. Purging a tag invalidates all URLs with that tag in one call, avoiding the need to enumerate every URL.
  3. Versioned URLs eliminate invalidation — the URL changes when content changes, so the CDN always fetches the new file. Old URLs just sit in cache until their TTL expires, which is harmless because nothing references them anymore.
  4. Soft purge sets the cached object’s TTL to a short value (e.g., 30 seconds) instead of deleting it. The CDN serves stale content while asynchronously fetching a fresh copy.

Choosing a strategy

flowchart diagram: Content changed

Provider comparison

The four strategies exist on every major CDN, but the mechanics differ enough that it’s worth picking a provider model before writing the integration:

ProviderPurge methodTag supportTypical propagationLimits and cost
Cloudflarepurge_cache endpoint (files, prefixes, tags, hosts, purge_everything)Cache-Tag header — tag purge requires an Enterprise plan~30–60 s globally~30 purge requests/min per zone; included in all plans
FastlyPOST /purge/{url} or /service/{id}/purge/{key}Surrogate-Key header on every plan, soft purge built in~1–5 s (instant purge network)Included; soft purge avoids origin spikes
AWS CloudFrontCreateInvalidation API with path patterns like /products/*No native tags — group by path prefix insteadSeconds to minutes per edge locationFirst 1,000 paths/month free, then charged per path

Two details that bite in production: Cloudflare’s tag purge is gated behind Enterprise — on lower plans you’re limited to URL, prefix, and host purges, so design your tag taxonomy around prefixes if you’re on a non-Enterprise plan. And CloudFront has no tag support at all, which pushes you toward versioned URLs and path-based invalidations rather than content grouping.

Runnable versions of these snippets — a single purge client covering all three providers plus a FastAPI webhook — live in the companion repository.

Variants

AWS CloudFront Invalidation

import time

import boto3

def invalidate_cloudfront(distribution_id: str, paths: list[str]) -> dict:
    """Create a CloudFront invalidation."""
    client = boto3.client("cloudfront")
    response = client.create_invalidation(
        DistributionId=distribution_id,
        InvalidationBatch={
            "Paths": {
                "Quantity": len(paths),
                "Items": paths,
            },
            "CallerReference": str(int(time.time())),
        },
    )
    return response

Selective Invalidation by Content Type

async def invalidate_product(
    product_id: str,
    category_id: str,
    zone_id: str,
    api_token: str,
) -> dict:
    """Invalidate all cache entries related to a product."""
    tags = [
        f"product-{product_id}",
        f"category-{category_id}",
        "search-results",
        "product-feed",
    ]
    return await purge_cloudflare_tags(zone_id, api_token, tags)

Invalidation Webhook

import os

from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

@app.post("/webhook/cdn-invalidate")
async def invalidate_webhook(request: Request):
    """Webhook endpoint for CMS-triggered invalidation."""
    payload = await request.json()
    if payload.get("secret") != os.getenv("WEBHOOK_SECRET"):
        raise HTTPException(status_code=403)

    if payload["type"] == "product_updated":
        await invalidate_product(
            payload["product_id"],
            payload["category_id"],
            os.getenv("CLOUDFLARE_ZONE_ID"),
            os.getenv("CLOUDFLARE_API_TOKEN"),
        )
    elif payload["type"] == "full_deploy":
        await invalidate_on_deploy()

    return {"status": "ok"}

Best Practices

  • Use surrogate keys for related content — purging one key invalidates all related URLs without enumerating them
  • Prefer versioned URLs for static assets — eliminates the need for invalidation entirely
  • Use soft purge for high-traffic pages — avoids origin spikes from simultaneous cache misses
  • Batch purge requests — most CDNs rate-limit purge API calls; batch URLs or use tags

Common Mistakes

  • Purging everything on every deploy — causes origin spikes; use surrogate keys for targeted invalidation
  • Not setting Surrogate-Key headers — without tags, you can only purge by URL, which requires knowing every affected URL
  • Purging instead of versioning — for static assets, versioned URLs are simpler and more reliable
  • Not handling purge API rate limits — Cloudflare allows 30 purge requests per minute per zone; batch accordingly

See Also

Frequently Asked Questions

How long does CDN invalidation take?

Typically 30–60 seconds for Cloudflare and 1–5 seconds for Fastly. CloudFront invalidations take longer — often a few minutes — because the invalidation has to reach every edge location. It depends on the CDN's edge propagation speed, so don't chain user-facing flows on a purge completing instantly.

Should I purge or use short TTLs?

Use short TTLs (60–300 seconds) for content that changes frequently — self-healing with zero integration work. Use purge for immediate invalidation when stale content is unacceptable, like a pricing fix or a takedown.

Can I purge by URL pattern (e.g., /products/*)?

Cloudflare supports prefix purging with "prefixes": ["/products/"]. Fastly uses surrogate keys for the same effect — tag everything under /products/ with a products key and purge the key. CloudFront accepts path wildcards directly in the invalidation paths.

What is the cost of CDN purge API calls?

Cloudflare includes purge in all plans but tag purging requires Enterprise. Fastly includes purging with per-plan rate limits. AWS CloudFront gives you 1,000 free invalidation paths per month and charges per path after that — wildcard paths count as one, which makes /products/* cheap but /products/item-1 through /products/item-900 expensive.

How do I confirm a purge actually worked?

Request the URL twice with curl -I. Right after the purge you should see a cache miss — CF-Cache-Status: MISS on Cloudflare or X-Cache: MISS on Fastly, usually with Age: 0. On the second request you should get a HIT again — this time serving the fresh object. If the first request still returns HIT, the purge hasn't propagated to that edge yet — wait a few seconds and retry.

What happens when thousands of cached objects expire at once?

You get an origin stampede: every edge node misses at the same time and hammers the origin. Avoid it with soft purge so nodes serve stale while revalidating, add jitter to TTLs so expirations spread out, and batch large purges instead of firing them all at once.

What is the difference between purge and ban in CDN caching?

Purge removes cached content immediately and the next request fetches a fresh copy from the origin. Ban marks content as stale but leaves it in cache — the next request triggers a revalidation. Use purge for immediate cleanup. Use ban when you want lazy invalidation to reduce origin load spikes.

How do I cache-bust without changing the URL?

You cannot. Browser and CDN caching is URL-based. To force a refresh, you must change the URL — either the filename (content hash), a query parameter (?v=2), or a path prefix (/v2/asset.css). Content hashing (e.g., [name].[contenthash].js) is the most reliable approach because the hash changes only when the file content changes.