Purging Cloudflare Cache by URL vs Cache-Tag

You have just deployed a new build and need stale HTML to disappear from every Cloudflare edge PoP within seconds. The decision between URL-based purge and Cache-Tag purge determines how many API calls you make, how precisely you target content, and whether your plan supports it at all.

Diagnosis: What Makes the Two Methods Different?

URL purge and Cache-Tag purge both call the same Cloudflare REST endpoint (POST /zones/{zone_id}/purge_cache) but with different payload keys. The underlying mechanism diverges at the Cloudflare data layer:

  • URL purge looks up cache objects by their exact request URL. Cloudflare indexes cached objects by URL natively on every plan.
  • Cache-Tag purge looks up cache objects by an arbitrary tag string you attached via the Cache-Tag response header. Cloudflare builds and maintains an inverted index from tag → set of cached object keys. This index is maintained only on Enterprise plans.

Both methods propagate to all edge PoPs within seconds. Neither requires you to know which edge PoP holds the object—Cloudflare broadcasts the purge instruction globally.

URL index versus tag inverted index Two side-by-side panels. On the left, a URL purge consumes one slot per exact URL and evicts one object each. On the right, a single Cache-Tag resolves through an inverted index to every object that carried that tag. Same endpoint, two different lookups URL purge one index slot per object / 1 object /blog/ 1 object /pricing/ 1 object 3 URLs consume 3 of the 30 slots Cache-Tag purge one tag, an inverted index html-pages 1,240 objects one tag consumes 1 of the 30 slots
Both calls hit the same endpoint. URL purge spends a slot per object; a tag spends one slot and resolves to the whole set.

The asymmetry that matters is not speed but how many objects one slot in the payload can reach. A URL slot reaches exactly one cached object; a tag slot reaches however many objects carried that tag when they were stored. That single property drives every decision below.

Comparison Table

Dimension URL Purge Cache-Tag Purge
Cloudflare plans Free, Pro, Business, Enterprise Enterprise only
Batch size per API call Up to 30 URLs Up to 30 tags (each tag covers unlimited URLs)
Targeting granularity One exact URL per slot All URLs sharing a tag (can be thousands)
Requires response header No Yes — Cache-Tag: your-tag on origin response
Works across hostnames No — URL includes hostname No — tags are zone-scoped
Works for non-deterministic URL sets No Yes
Implementation overhead None — URL is known at build time Moderate — must emit header, maintain tag taxonomy
Typical use for hashed assets Purge HTML entry-point URLs Tag entire HTML route groups by deploy ref
Typical use for un-hashed assets Targeted single-resource purge Purge a content category (e.g., all /blog/ pages)

When URL Purge Fits Fingerprinted Assets

For a frontend application that uses content hashing to fingerprint every JS, CSS, and image file, the set of URLs that need purging after a deploy is small and deterministic: the HTML entry points. A typical Vite SPA has one HTML route (/). A Next.js app with static export might have ten to fifty.

URL purge is the right tool when:

  • Your HTML route count is under 100
  • Routes are known at build time (listed in a sitemap or generated by your static site builder)
  • You are on any Cloudflare plan (Free through Enterprise)
  • You want zero infrastructure overhead—no tag emission, no tag taxonomy
# Purge three HTML entry points after deploy — complete and runnable
ZONE_ID="your_zone_id_here"
API_TOKEN="your_api_token_here"

curl -s -X POST \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{
    "files": [
      "https://www.example.com/",
      "https://www.example.com/blog/",
      "https://www.example.com/pricing/"
    ]
  }'

The response includes {"success": true} and a result.id for the purge job. The eviction propagates to all edge PoPs within a few seconds.

For sites with more than 30 HTML routes, batch the calls. Read the URL list from a file and POST in chunks of 30:

ZONE_ID="your_zone_id_here"
API_TOKEN="your_api_token_here"
URL_FILE="html_routes.txt"  # one URL per line

# Split into chunks of 30 and purge each chunk
split -l 30 "${URL_FILE}" /tmp/purge_chunk_

for chunk in /tmp/purge_chunk_*; do
  PAYLOAD=$(jq -Rsc 'split("\n") | map(select(length > 0))' < "${chunk}")
  curl -s -X POST \
    "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
    -H "Authorization: Bearer ${API_TOKEN}" \
    -H "Content-Type: application/json" \
    --data "{\"files\": ${PAYLOAD}}"
  sleep 1
done

rm -f /tmp/purge_chunk_*

When Cache-Tag Purge Fits Fingerprinted Assets

Cache-Tag purge becomes necessary when your URL set is large, dynamic, or not enumerable at deploy time. Common scenarios:

  • A CMS-driven site where pages are created by editors and the full URL list is not known at build time
  • A multi-tenant SaaS with hundreds of tenant-specific routes per deploy
  • A large e-commerce site with thousands of category and product pages that share a common layout file

On Enterprise plans, attach Cache-Tag response headers to every HTML response, then purge by tag after each deploy. A single tag can cover the entire site’s HTML.

Emitting Cache-Tag from Nginx

location ~* "\.(html)$" {
    add_header Cache-Tag "html-pages";
    add_header Cache-Control "no-cache";
    try_files $uri =404;
}

location / {
    # For directory-style HTML routes (index.html served without extension)
    add_header Cache-Tag "html-pages";
    add_header Cache-Control "no-cache";
    try_files $uri/index.html $uri =404;
}

Emitting Cache-Tag via Cloudflare Transform Rules

If you cannot change your origin response headers, use a Cloudflare HTTP Response Header Transform Rule:

ZONE_ID="your_zone_id_here"
API_TOKEN="your_api_token_here"

curl -s -X POST \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/rulesets/phases/http_response_headers_transform/entrypoint/rules" \
  -H "Authorization: Bearer ${API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{
    "description": "Attach Cache-Tag to HTML responses",
    "expression": "(http.response.content_type.media_type eq \"text/html\")",
    "action": "rewrite",
    "action_parameters": {
      "headers": {
        "Cache-Tag": {
          "operation": "set",
          "value": "html-pages"
        }
      }
    }
  }'

Purging by Tag

ZONE_ID="your_zone_id_here"
API_TOKEN="your_api_token_here"

curl -s -X POST \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{
    "tags": ["html-pages"]
  }'

One API call evicts every HTML object in the zone, regardless of how many URLs exist. The CI/CD deploy step becomes a single curl command with no URL list to maintain.

For finer-grained tag strategies, use multiple tags: tag by deploy commit (deploy-abc1234), by content section (section-blog), or by template (template-product-page). You can attach multiple tags to a single response by separating them with commas:

add_header Cache-Tag "html-pages,section-blog,deploy-abc1234";

Then purge by any combination of tags independently.

Designing the Tag Taxonomy

Three layers cover almost every real deployment, and adding a fourth usually means you are encoding data that belongs in a URL instead. Emit a broad tag that covers everything cacheable, a structural tag that matches how content is grouped, and a build-identity tag carrying the commit or release ref.

Three-layer tag taxonomy One HTML response carries three Cache-Tag values. A broad site tag purges every HTML object, a section tag purges one route group, and a build tag purges only the objects stored during a single release. attached at origin evicted by one purge call HTML response Cache-Tag header carries three values site-html the broad safety net section-blog structural grouping build-9f3a2b11 release identity every HTML object cached the blog routes only objects stored by one build Purge the narrowest tag that covers the change you shipped
Three tag layers on one response give you three purge scopes without three sets of URL lists.

The build tag is the one people skip and later wish they had. Because a tag is recorded when the object is stored, build-9f3a2b11 names exactly the set of objects Cloudflare cached while that release was live. After a rollback you can evict precisely that generation of HTML without touching anything an editor published since — a scope that URL purge cannot express at all, because the URLs are identical across builds.

Keep tag values short, lowercase, and free of characters that need escaping. Cloudflare lower-cases tags on ingest and treats them as opaque strings, so Section-Blog and section-blog are the same tag but reading a mixed-case tag list in a log later is needlessly confusing.

Choosing a purge method A decision tree starting at deploy completion, branching on whether the zone is Enterprise, then on HTML route count or whether URLs can be enumerated, ending in single URL purge, batched URL purge, or Cache-Tag purge. Deploy finished stale HTML must go Enterprise zone? tags need Enterprise No Yes Routes 30 or fewer? and known at build time URLs enumerable? from a sitemap or build Yes No Yes No URL purge one API call Batched URLs 30 URLs per call URL purge simpler, no tags Cache-Tag one call, all
Plan tier decides whether tags are available at all; route count and enumerability decide whether they are worth the taxonomy.

Verification

After running a purge, confirm the target URLs show cf-cache-status: MISS or EXPIRED on the next request, then HIT on the subsequent one:

ZONE_ID="your_zone_id_here"
API_TOKEN="your_api_token_here"

# 1. Trigger the purge
curl -s -X POST \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"files": ["https://www.example.com/"]}'

# 2. First request after purge — should be MISS (edge refetching from origin)
curl -sI "https://www.example.com/" | grep -i "cf-cache-status"
# Expected: cf-cache-status: MISS

# 3. Second request — should be HIT (edge has cached the fresh response)
curl -sI "https://www.example.com/" | grep -i "cf-cache-status"
# Expected: cf-cache-status: HIT

For Cache-Tag purge verification, check a sample of URLs that carried the purged tag. All should return MISS on the first request post-purge. If some return HIT, the tag was not emitted on those responses—inspect the response headers from origin to confirm the Cache-Tag header is present.

# Confirm Cache-Tag header is reaching Cloudflare from origin
curl -sI "https://www.example.com/" | grep -i "cache-tag"
# Expected: cache-tag: html-pages

Note: Cloudflare strips the Cache-Tag header before forwarding to browsers, so your end users never see it—only the Cloudflare caching layer reads it.

Limits That Make a Tag Purge Silently Miss

A URL purge fails loudly: a malformed URL returns an error and the call is rejected as a whole. A tag purge fails quietly, because the tag simply resolves to fewer objects than you expected, the API still answers {"success": true}, and nothing in the response tells you the set was short. Every one of the limits below produces that outcome.

Limit Value Failure mode when exceeded
Cache-Tag header length 16 KB total per response Cloudflare drops the whole header; the object is cached with no tags at all
Tag length 1,024 bytes each The over-long tag is discarded, the rest survive
Tags per response 125 Tags past the limit are dropped in header order
Tags per purge call 30 The call is rejected, so this one is loud
Tag characters no spaces after the comma separator A leading space becomes part of the tag string and never matches

The 16 KB ceiling sounds unreachable until a CMS starts emitting one tag per rendered entity — a category page listing 400 products with a per-product tag crosses it easily, and the symptom is that every tag on that page stops working, not just the last few. Keep per-response tag counts in the single digits and let structural tags do the grouping.

The second silent failure has nothing to do with limits: a tag is recorded only when the object enters the cache. If you add the Cache-Tag header today, objects cached yesterday carry no tag and a purge by that tag will not touch them. After introducing tags, run one prefix or hostname purge to force everything to be re-stored with tags attached, then rely on tag purges from that point on.

# One-time backfill: force every object to be re-cached with the new tags
ZONE_ID="your_zone_id_here"
API_TOKEN="your_api_token_here"

curl -s -X POST \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"hosts": ["www.example.com"]}'

Operational Cost per Deploy

The API call count is the wrong thing to optimise; both methods finish in well under a second. What differs is the amount of state your pipeline has to keep correct, and where that state rots when nobody is looking.

Cost URL purge Cache-Tag purge
State the pipeline must maintain An accurate route list An accurate tag emission rule
What rots silently A new route nobody added to the list stays stale until its TTL elapses A response that stopped emitting the header is never evicted
Calls for 900 HTML routes 30 1
Behaviour when a route is renamed Old URL keeps serving until TTL expiry Covered automatically by the broad tag
Debugging surface The exact URL is in the request log You must inspect origin response headers

Neither column is free. The URL list rots by omission, the tag rule rots by silence, and both fail the same way from a user’s point of view. The mitigation is symmetric: assert on it. A pipeline that purges by URL should diff its route list against the generated sitemap and fail the build on a mismatch. A pipeline that purges by tag should curl one representative route after deploy and fail if the Cache-Tag header is missing.

Behaviour with Tiered Cache Enabled

Both purge types are broadcast to lower-tier and upper-tier PoPs together, so neither method leaves a stale copy sitting in the upper tier waiting to re-seed the edge. The difference shows up in what happens next: a tag purge that evicts 1,240 HTML objects across every tier means the next request for each of them is an origin fetch, funnelled through the upper tier. Purging one URL sends one request to origin; purging a broad tag can send several hundred within a few seconds of the next traffic wave, which is exactly the shaping problem Tiered Cache and hashed asset propagation addresses. Fingerprinted assets are untouched by either purge, so the refetch volume is bounded by HTML size rather than bundle size — a few hundred kilobytes, not a few hundred megabytes.

When to Reconsider

Switch from URL purge to Cache-Tag purge when your HTML route set grows beyond 100 routes, when routes are generated dynamically by editors or users, or when your CI/CD pipeline cannot enumerate the full URL list at deploy time. The up-front cost of emitting tags pays off as soon as batching URL purges becomes a maintenance burden.

Stay with URL purge if you are on Free, Pro, or Business plans (Cache-Tags require Enterprise), or if your site has fewer than 30 HTML routes and the URL list is stable. URL purge involves zero infrastructure change and is always available.

Neither method is right for hashed asset URLs. If you find yourself purging /assets/app.a1b2c3d4.js frequently, the underlying problem is that your asset filenames are not truly fingerprinted. Fix the content hash configuration in your build tool, not the purge strategy. The Cloudflare Cache Rules and purge guide explains how to configure Cache Rules so hashed assets never need purging.

FAQ

Can I purge by Cache-Tag on a Pro or Business plan?

No. The tag-to-object index is only maintained on Enterprise zones. You can emit Cache-Tag headers on any plan and Cloudflare will accept them without error, which makes this easy to misdiagnose — the purge call returns {"success": true} and evicts nothing. On Pro and Business, prefix purge is the closest substitute for grouping.

Do fingerprinted asset URLs need a Cache-Tag?

No, and tagging them creates a hazard. A tag gives you a way to evict immutable objects that never need eviction, and the one time someone uses it under pressure they cold-start the entire asset set. Tag HTML responses only; leave hashed assets with no tag so the destructive option does not exist.

Why did my tag purge evict some URLs but not others?

Almost always because the untouched objects were cached before the Cache-Tag header started being emitted, or because those particular responses exceeded the 16 KB header budget and had their tags dropped. Check with curl -sI against one working and one non-working URL and compare the cache-tag header at origin.