FrontCore
Caching & Storage

CDN Cache Purging

How CDNs cache at the edge, URL/tag/wildcard purging strategies, Cloudflare and Fastly APIs, origin shields and request coalescing for stampede prevention, Vercel Edge Network specifics, and tying purge calls to your deploy pipeline.

CDN Cache Purging
CDN Cache Purging

Overview

A CDN caches your responses — HTML, JSON, images, scripts — across globally distributed edge nodes. When a user requests a resource, the CDN serves it from the nearest edge instead of forwarding to your origin. Latency drops from hundreds of milliseconds to single-digit milliseconds.

The tradeoff: the CDN serves the cached version until its TTL expires — or until you explicitly purge it. A deploy that fixes a bug is invisible to users until either the TTL expires or you send a purge request. Getting this wrong means users see broken UIs, outdated API responses, or stale pricing for minutes or hours after a fix ships.

Cache purging tells the CDN to discard a cached entry and fetch a fresh copy from origin on the next request.


How It Works

When a response travels from your origin to the CDN edge, it carries cache headers:

Cache-Control: public, s-maxage=3600

The CDN stores the response and serves it for 3,600 seconds without touching your origin. Purging bypasses that TTL — you send an API request to the CDN, it marks the entry stale immediately, and the next user request fetches fresh from origin.

Three purging models exist at every major CDN:

URL-based — invalidate one or more exact URLs. Simple, precise, brittle at scale.

Wildcard / prefix-based — invalidate everything under /api/products/*. Broader, works when URLs are predictable patterns.

Cache-tag / surrogate-key — tag responses at the origin with a custom header; purge all responses sharing a tag with one API call. Most scalable, decouples invalidation from URL structure.


Code Examples

Setting Cache Headers at the Origin

// app/api/products/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  const products = await db.product.findMany({ where: { active: true } });

  return NextResponse.json(products, {
    headers: {
      // CDN caches for 1 hour. Serve stale for 5 extra minutes while revalidating.
      "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=300",

      // Cloudflare Cache-Tag — enables tag-based purge via API
      "Cache-Tag": "products",

      // Fastly Surrogate-Key — Fastly's equivalent of Cache-Tag
      "Surrogate-Key": "products",

      // Surrogate-Control — Fastly-specific, overrides Cache-Control for the CDN
      // (browsers ignore Surrogate-Control; only Fastly reads it)
      "Surrogate-Control": "max-age=3600",
    },
  });
}

URL-Based Purging — Cloudflare

// lib/cdn/cloudflare.ts

const CF_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID!;
const CF_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN!;
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;

export async function purgeByUrls(paths: string[]): Promise<void> {
  const urls = paths.map((p) => `${SITE_URL}${p}`);

  const res = await fetch(
    `https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${CF_API_TOKEN}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ files: urls }),
    },
  );

  if (!res.ok) {
    const err = await res.json();
    throw new Error(`Cloudflare purge failed: ${JSON.stringify(err.errors)}`);
  }
}

Tag-Based Purging — Cloudflare

// lib/cdn/cloudflare.ts (continued)

export async function purgeByTag(tag: string): Promise<void> {
  const res = await fetch(
    `https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${CF_API_TOKEN}`,
        "Content-Type": "application/json",
      },
      // Purges ALL responses tagged "products" across all edge nodes globally
      body: JSON.stringify({ tags: [tag] }),
    },
  );

  if (!res.ok) throw new Error("Cloudflare tag purge failed");
}

// Tie to a write operation
export async function updateProduct(
  id: string,
  data: { name: string; price: number },
) {
  await db.product.update({ where: { id }, data });

  // One call clears every CDN edge response tagged "products"
  await purgeByTag("products");

  // Also purge the specific product detail
  await purgeByUrls([`/api/products/${id}`, `/products/${id}`]);

  // Bust Next.js internal data cache too
  revalidateTag("products");
}

Cache-tag purging on Cloudflare requires the Pro plan or higher. On the free plan, only URL-based and full-zone purges are available.


Surrogate Keys — Fastly

Fastly uses Surrogate-Key headers (instead of Cache-Tag) and the Surrogate-Control header for CDN-only TTLs:

// lib/cdn/fastly.ts

const FASTLY_API_KEY = process.env.FASTLY_API_KEY!;
const FASTLY_SERVICE_ID = process.env.FASTLY_SERVICE_ID!;

export async function fastlyPurgeByKey(surrogateKey: string): Promise<void> {
  const res = await fetch(
    `https://api.fastly.com/service/${FASTLY_SERVICE_ID}/purge/${surrogateKey}`,
    {
      method: "POST",
      headers: {
        "Fastly-Key": FASTLY_API_KEY,
        Accept: "application/json",
        // Soft purge: marks stale but serves while revalidating
        // Omit for hard purge (removes immediately)
        "Fastly-Soft-Purge": "1",
      },
    },
  );

  if (!res.ok) throw new Error(`Fastly purge failed for key: ${surrogateKey}`);
}

// Bulk purge by multiple surrogate keys
export async function fastlyPurgeByKeys(keys: string[]): Promise<void> {
  const res = await fetch(
    `https://api.fastly.com/service/${FASTLY_SERVICE_ID}/purge`,
    {
      method: "POST",
      headers: {
        "Fastly-Key": FASTLY_API_KEY,
        "Content-Type": "application/json",
        "Fastly-Soft-Purge": "1",
      },
      body: JSON.stringify({ surrogate_keys: keys }),
    },
  );

  if (!res.ok) throw new Error("Fastly bulk purge failed");
}

Fastly soft purge vs hard purge: A soft purge marks entries as stale — they continue to be served under stale-while-revalidate while a background revalidation runs. A hard purge removes them immediately — the next request triggers a full origin fetch with no stale fallback. Use soft purge for content updates (users continue getting a response); use hard purge for data that must be immediately removed (deleted content, security-sensitive data).


Vercel Edge Network — Integrated Purging

On Vercel, revalidateTag and revalidatePath from next/cache purge both the Next.js Data Cache and the Vercel Edge Network cache simultaneously — no separate API calls needed:

// app/actions/publish-post.ts
"use server";

import { revalidateTag, revalidatePath } from "next/cache";

export async function publishPost(slug: string) {
  await db.post.update({ where: { slug }, data: { published: true } });

  // This purges Next.js data cache AND Vercel Edge Network cache for tagged responses
  revalidateTag(`post-${slug}`);
  revalidateTag("posts");

  // Also purges the rendered HTML at these paths from the Edge Network
  revalidatePath(`/blog/${slug}`);
  revalidatePath("/blog");
}

For non-Next.js deployments on Vercel or when you need direct access to the Edge Network purge API:

// lib/cdn/vercel.ts
export async function vercelPurgePath(pathname: string): Promise<void> {
  const res = await fetch(
    `https://api.vercel.com/v1/edge-cache/purge?teamId=${process.env.VERCEL_TEAM_ID}`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.VERCEL_API_TOKEN}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ paths: [pathname] }),
    },
  );

  if (!res.ok) throw new Error("Vercel edge cache purge failed");
}

Origin Shield — Preventing Stampedes on Purge

Without an origin shield, a purge event across 200 global edge nodes triggers 200 simultaneous origin requests (one "fill" per edge node). With an origin shield, all edge nodes route through a single shield datacenter that makes one upstream request to your origin:

Without shield: Purge → 200 edge nodes → 200 origin requests (stampede)
With shield:    Purge → 200 edge nodes → 1 shield node → 1 origin request

Configure on Cloudflare via the dashboard: Caching → Tiered Cache → Smart Tiered Cache Topology. On Fastly: Shielding setting per service.


Purge-on-Deploy Pipeline

Wire purge calls into your CI/CD pipeline so stale HTML is cleared automatically after every production deploy:

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to production
        run: vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }}
        # Or your deploy command (fly deploy, aws s3 sync, etc.)

      - name: Purge CDN cache after deploy
        run: |
          # Purge HTML pages likely affected by the deploy
          curl -X POST "https://api.cloudflare.com/client/v4/zones/${{ secrets.CF_ZONE_ID }}/purge_cache" \
            -H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
            -H "Content-Type: application/json" \
            --data '{"files": ["https://example.com/", "https://example.com/products", "https://example.com/about"]}'

          # Or purge by prefix if the CDN supports it
          # --data '{"prefixes": ["https://example.com/"]}'
        env:
          CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
          CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}

For content-hashed static assets (main.a3f2c9d1.js) no purge is needed — the filename changes with every deploy, so old files are simply never requested again. Purge is needed for stable-URL resources: HTML pages, API responses, images at fixed paths.


Real-World Use Case

SaaS marketing site with blog. Static pages (/, /pricing, /about) are cached at the CDN edge for 24 hours — they change only when engineering deploys. A post-deploy pipeline purges those exact URLs. Blog posts are tagged with post-{slug} and posts; when a writer publishes a correction in the CMS, a webhook fires POST /api/revalidate which calls purgeByTag("post-my-slug") via the Cloudflare API. The blog index updates within seconds. API responses for the pricing widget use Surrogate-Key: pricing and are purged immediately when pricing changes — customers see the new price within one request after a sales team update. The origin shield means even after a global purge, only one origin request refills the cache per page, not hundreds.


Common Mistakes / Gotchas

1. Purging without an origin shield. A full-zone purge without a shield can send hundreds of simultaneous fill requests to your origin — often causing the same outage you were trying to fix. Enable Tiered Cache (Cloudflare) or Shielding (Fastly) before doing large-scale purges.

2. Forgetting browser cache. CDN purging clears the edge cache. Users who have the old response in their browser cache still see stale content until their browser TTL expires. For HTML pages, use short max-age (60–300s) so browser caches turn over quickly even without explicit invalidation.

3. URL-based purging at scale. Purging 10,000 individual product URLs on every price update is both slow and rate-limited. Use tag-based purging — one API call clears all products-tagged responses regardless of URL count.

4. Not handling purge API failures. Purge API calls can fail due to network errors, rate limits, or CDN API outages. If a purge silently fails, users continue seeing stale data. Add retry logic with exponential backoff and alerting for purge failures in production.

5. Purging stale-while-revalidate content and expecting immediate freshness. If a response has stale-while-revalidate=300, the CDN may still serve the stale version for up to 5 minutes after purge while a background revalidation is in-flight. For content that must be immediately fresh after purge, use a hard purge (not soft) and set stale-while-revalidate=0 on that resource.


Summary

CDNs cache responses at globally distributed edges to reduce latency and origin load. Cache purging invalidates entries on demand, bypassing the TTL. URL-based purging is precise but doesn't scale; tag/surrogate-key purging invalidates many related responses with one API call and is the correct model for dynamic applications. Cloudflare uses Cache-Tag headers and the zones purge API; Fastly uses Surrogate-Key headers with soft and hard purge modes. Vercel's revalidateTag purges both the Next.js Data Cache and the Edge Network simultaneously. Origin shields collapse hundreds of post-purge fill requests into one. Wire purge calls to write operations for data freshness, and to your deploy pipeline for HTML page freshness.


Interview Questions

Q1. What is the difference between URL-based purging and tag-based purging and when do you use each?

URL-based purging invalidates one or more exact cache entries by their full URLs. It's precise and requires no setup beyond knowing the URLs — but it doesn't scale: purging 10,000 product pages after a site-wide layout change requires 10,000 API calls (often rate-limited). Tag-based purging attaches tags to responses at the origin via Cache-Tag (Cloudflare) or Surrogate-Key (Fastly) headers. A single purge API call clears every edge response sharing that tag, regardless of URL count. Use URL-based purging for a small, known set of pages that change independently. Use tag-based for any resource that can be grouped by type (all product pages, all blog posts, all API responses for a given entity) — a single write operation maps to a single purge call.

Q2. What is an origin shield and why is it critical for safe cache purging?

An origin shield is a designated "shield" datacenter that sits between CDN edge nodes and your origin server. Without a shield, when a popular cache entry expires or is purged, every global edge node that receives a request for that resource independently fetches it from origin — potentially hundreds of simultaneous origin requests for a single purge event (the thundering herd / cache stampede). With a shield, edge nodes forward cache misses to the shield node first; the shield makes one upstream request to origin and serves the result to all edge nodes. Post-purge, the storm of fill requests collapses to one. Enable Cloudflare's "Smart Tiered Cache" or Fastly's "Shielding" before running any large-scale purge operations.

Q3. What is Fastly's "soft purge" and how does it differ from a hard purge?

A Fastly soft purge (Fastly-Soft-Purge: 1) marks a cache entry as stale without removing it. The entry continues to be served under stale-while-revalidate semantics — users get the stale response immediately while a background revalidation fetches the fresh version. A hard purge (no Fastly-Soft-Purge header) removes the entry immediately. The next request for that resource triggers a full origin fetch with no stale fallback — if the origin is slow or unavailable, users see a latency spike or error. Soft purge is the better default for content updates: users experience no interruption. Hard purge is appropriate for deleted content (a post taken down for legal reasons) or security-sensitive data that must never be served again.

Q4. How does revalidateTag on Vercel differ from calling the Cloudflare purge API directly?

On Vercel, revalidateTag("products") performs two invalidations atomically: it purges the entry from Next.js's internal Data Cache (the fetch-level cache used by Server Components and Route Handlers) and simultaneously purges the corresponding rendered output from the Vercel Edge Network. A direct Cloudflare API call purges only the edge layer — the CDN response is gone, but Next.js's internal Data Cache may still have a stale copy. The next origin request would re-render using stale Data Cache data and push that stale HTML back to the CDN. On Vercel, revalidateTag is the correct and sufficient tool — no additional CDN API call is needed. On non-Vercel deployments, you must coordinate both Next.js data cache invalidation and CDN purge manually.

Q5. Why doesn't CDN purging help users who already have the response in their browser cache?

CDN purging clears entries from edge nodes — shared caches that serve many users. But the browser also maintains its own private cache, governed by the max-age directive in Cache-Control. A user who fetched a resource with max-age=3600 one hour ago has that response in their browser cache and won't request it again from the CDN (or origin) for another hour — regardless of CDN purge events. The mitigations: use short max-age values (60–300s) for HTML pages that change on deploy, so browser caches turn over quickly. Use content-hashed filenames for static assets — when the asset changes, the URL changes, so old cached versions are never requested again. For critical correctness issues, the only reliable solution is short browser max-age.

Q6. What is a thundering herd in CDN caching and how do you prevent it on high-traffic entries?

A thundering herd occurs when a popular cache entry expires (or is purged) and many concurrent user requests simultaneously see a cache miss — all forwarding to origin simultaneously. For an endpoint receiving 10,000 requests/second, even a 100ms cache expiry window can trigger thousands of simultaneous origin requests. Three prevention strategies: (1) enable origin shielding so only the shield datacenter contacts origin; (2) add TTL jitter — instead of revalidate: 3600 exactly, use a random value in the range 3060–3540 so entries across cache nodes don't expire simultaneously; (3) use stale-while-revalidate so the CDN serves the stale response to all users while a single background revalidation updates the cache, rather than all users hitting origin on expiry.

On this page