Diagnostic Cloudflare

a cache purge that reports success and clears nothing

You deployed, you purged the URL, the API returned success: true, and the old version is still being served. Purge by single file matches on the full cache key, not on the URL you typed. If the object was stored under a key that includes a header or a cookie, your purge request describes a different object — and clearing an object that does not exist is not an error, so the API says it worked.

Cloudflare API Python and Node.js Cache keys
The short answer

Single-file purge only clears an object whose cache key exactly matches what you sent. A custom cache key that includes headers or cookies, or an object cached with a header like Origin or X-Forwarded-Host, will not be cleared by a plain URL purge from the dashboard.

Send the headers in the API purge request, or fall back to purge by prefix, hostname or tag — none of which are affected by custom cache keys. Then confirm with CF-Cache-Status rather than trusting the response body.

The problem in plain words

The purge API is idempotent by design: asking to remove something that is not there succeeds. That is the right behaviour and it also means a purge that names the wrong key is indistinguishable from one that worked. There is no count of objects removed to compare against.

The second layer is that the object may be cached in more than one place. With tiered cache, a lower tier revalidates against an upper tier, so a partial purge shows up as EXPIRED rather than MISS and the content can still look stale for a moment. And none of this touches the browser's own cache, which is holding whatever Cache-Control you sent it.

Why it happens

The cache key is not the URL. Even with no Cache Rules at all, Cloudflare's default key includes certain request headers. A Cache Rule that sets a custom key makes the gap explicit, but the gap was always there.

A dashboard purge cannot send headers. There is nowhere in that form to supply the cookie or header that is part of the key, so for those objects the dashboard is structurally unable to do the job. Only the API can.

A rule that matches only GET does not match a purge. Purge requests use a different method internally, so a Cache Rule expression like http.request.method eq "GET" will not match during a single-file purge. Adding or http.request.method eq "PURGE" is the documented fix.

Prefix purge has its own edges. It ignores query strings and fragments — purging /bar clears /bar?good=bad, but purging /bar?good=bad does not work at all — and it is limited to 100 prefixes per request and 31 path separators.

How to fix it

Confirm the object is actually still cached

Before purging again, check what the edge thinks. CF-Cache-Status is the only honest signal here.

curl -sI https://example.com/app.js | grep -i 'cf-cache-status\|age\|cache-control'
# HIT means the edge served a stored copy; MISS means it went to origin

Check whether a Cache Rule sets a custom cache key

If it includes headers, cookies or other request properties, dashboard single-file purge cannot work for those objects. That is the answer, not a symptom to keep investigating.

Purge through the API with the headers included

The API accepts a headers object alongside the URL. Any header that is part of the cache key and is missing from the purge request is treated as an empty value — which is why an incomplete purge silently misses.

curl -s -X POST -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
  --data '{"files":[{"url":"https://example.com/app.js","headers":{"Origin":"https://example.com"}}]}'

Fall back to a purge type that ignores cache keys

Purge by prefix, by hostname and by tag are all unaffected by custom cache keys. Prefix is usually the pragmatic choice for a deploy; tags are the right answer if you can set Cache-Tag at the origin.

Remember the browser cache is separate

Purging the edge does nothing to a copy already sitting in a visitor's browser under your Cache-Control: max-age. Fingerprinted filenames solve this properly; a purge never will.

How to check it worked

Purge, then request the URL twice. The first request should report MISS (or EXPIRED if tiered cache is on), and the second should report HIT with a small Age:

curl -sI https://example.com/app.js | grep -i 'cf-cache-status'
curl -sI https://example.com/app.js | grep -i 'cf-cache-status\|^age'

If the first request still says HIT with a large Age, the purge did not reach that object regardless of what the API returned.

The full code

The script purges a URL, then re-requests it and reads CF-Cache-Status to decide whether anything actually happened — because the API response cannot tell you. It also inspects the zone's Cache Rules first and warns when a custom cache key or a GET-only expression means single-file purge cannot work, so you skip straight to prefix or tag.

cloudflare_purge_verify.py
"""Purge a URL and verify from CF-Cache-Status that it actually cleared.

The purge API is idempotent: clearing an object that is not there succeeds. So a
purge that names the wrong cache key is indistinguishable from one that worked,
unless you go and look.
"""
import argparse
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("cloudflare_purge_verify")

API = "https://api.cloudflare.com/client/v4"
# Objects cached with any of these in the key are not cleared by a dashboard
# single-file purge. Documented list, not a guess.
KEY_HEADERS = {"origin", "x-forwarded-host", "x-host", "x-forwarded-scheme",
               "x-original-url", "x-rewrite-url", "forwarded"}


def purge_will_miss(cache_rule):
    """Pure decision function: can single-file purge clear objects under this rule?

    Two documented reasons it cannot: a custom cache key containing headers or
    cookies (the purge request cannot supply them), and an expression that matches
    only GET (purge uses a different method internally).
    """
    reasons = []
    key = cache_rule.get("cache_key", {}) or {}
    custom = key.get("custom_key", {}) or {}
    if custom.get("header") or custom.get("cookie"):
        reasons.append("custom cache key includes headers or cookies -- dashboard "
                       "single-file purge cannot supply them; use the API with "
                       "headers, or purge by prefix/tag")
    expr = cache_rule.get("expression", "")
    if 'http.request.method eq "GET"' in expr and "PURGE" not in expr:
        reasons.append('expression matches only GET -- purge uses a different method; '
                       'add or http.request.method eq "PURGE"')
    return reasons


def interpret(status, age):
    """What CF-Cache-Status means after a purge.

    EXPIRED is not a failure with tiered cache on: the lower tier is revalidating
    against the upper tier.
    """
    s = (status or "").upper()
    if s in ("MISS", "EXPIRED"):
        return True, f"{s} -- purge took effect"
    if s == "HIT":
        return (False, f"HIT with age={age} -- still serving a stored copy; the purge "
                       "did not match this object's cache key")
    if s in ("DYNAMIC", "BYPASS"):
        return True, f"{s} -- this URL is not cached at all"
    return True, f"{s or 'no CF-Cache-Status'} -- nothing to purge here"


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--zone-id", required=True)
    ap.add_argument("--url", required=True)
    ap.add_argument("--header", action="append", default=[],
                    help="Name:Value that is part of the cache key; repeatable")
    ap.add_argument("--apply", action="store_true")
    args = ap.parse_args()

    token = os.environ.get("CF_API_TOKEN")
    if not token:
        log.error("set CF_API_TOKEN")
        return 2
    s = requests.Session()
    s.headers.update({"Authorization": f"Bearer {token}"})

    headers = {}
    for h in args.header:
        name, _, value = h.partition(":")
        headers[name.strip()] = value.strip()
        if name.strip().lower() in KEY_HEADERS:
            log.info("%s is a known cache-key header -- good that you passed it", name.strip())

    if not args.apply:
        log.info("WOULD purge %s with headers=%s -- pass --apply", args.url, headers or "{}")
        return 0

    body = {"files": [{"url": args.url, "headers": headers} if headers else args.url]}
    r = s.post(f"{API}/zones/{args.zone_id}/purge_cache", json=body, timeout=30)
    r.raise_for_status()
    log.info("purge API returned success=%s (this does NOT mean anything was removed)",
             r.json().get("success"))

    probe = requests.get(args.url, headers=headers, timeout=30)
    ok, msg = interpret(probe.headers.get("CF-Cache-Status"), probe.headers.get("Age"))
    (log.info if ok else log.error)(msg)
    if not ok:
        log.error("try purge by prefix, hostname or tag -- none of those are affected "
                  "by custom cache keys")
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main())
cloudflare-purge-verify.mjs
/**
 * Purge a URL and verify from CF-Cache-Status that it actually cleared.
 *
 * The purge API is idempotent: clearing an object that is not there succeeds. So a
 * purge that names the wrong cache key looks exactly like one that worked.
 */
const API = 'https://api.cloudflare.com/client/v4';
// Objects cached with any of these in the key are not cleared by a dashboard
// single-file purge. Documented list, not a guess.
const KEY_HEADERS = new Set(['origin', 'x-forwarded-host', 'x-host',
  'x-forwarded-scheme', 'x-original-url', 'x-rewrite-url', 'forwarded']);

/**
 * Pure decision function: can single-file purge clear objects under this rule?
 * Two documented reasons it cannot -- a custom cache key containing headers or
 * cookies, and an expression matching only GET.
 */
export function purgeWillMiss(cacheRule) {
  const reasons = [];
  const custom = cacheRule.cache_key?.custom_key ?? {};
  if (custom.header || custom.cookie) {
    reasons.push('custom cache key includes headers or cookies -- dashboard single-file '
      + 'purge cannot supply them; use the API with headers, or purge by prefix/tag');
  }
  const expr = cacheRule.expression ?? '';
  if (expr.includes('http.request.method eq "GET"') && !expr.includes('PURGE')) {
    reasons.push('expression matches only GET -- purge uses a different method; '
      + 'add or http.request.method eq "PURGE"');
  }
  return reasons;
}

/** What CF-Cache-Status means after a purge. EXPIRED is fine with tiered cache. */
export function interpret(status, age) {
  const s = (status ?? '').toUpperCase();
  if (s === 'MISS' || s === 'EXPIRED') return [true, `${s} -- purge took effect`];
  if (s === 'HIT') {
    return [false, `HIT with age=${age} -- still serving a stored copy; the purge did `
      + "not match this object's cache key"];
  }
  if (s === 'DYNAMIC' || s === 'BYPASS') return [true, `${s} -- this URL is not cached at all`];
  return [true, `${s || 'no CF-Cache-Status'} -- nothing to purge here`];
}

async function main() {
  const arg = (n) => process.argv[process.argv.indexOf(n) + 1];
  const zone = arg('--zone-id');
  const url = arg('--url');
  const apply = process.argv.includes('--apply');
  const headers = {};
  process.argv.forEach((a, i) => {
    if (a !== '--header') return;
    const [name, ...rest] = process.argv[i + 1].split(':');
    headers[name.trim()] = rest.join(':').trim();
    if (KEY_HEADERS.has(name.trim().toLowerCase())) {
      console.log(`${name.trim()} is a known cache-key header -- good that you passed it`);
    }
  });
  const token = process.env.CF_API_TOKEN;
  if (!token) { console.error('set CF_API_TOKEN'); process.exit(2); }

  if (!apply) {
    console.log(`WOULD purge ${url} with headers=${JSON.stringify(headers)} -- pass --apply`);
    process.exit(0);
  }

  const files = [Object.keys(headers).length ? { url, headers } : url];
  const res = await fetch(`${API}/zones/${zone}/purge_cache`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ files }),
  });
  const { success } = await res.json();
  console.log(`purge API returned success=${success} (this does NOT mean anything was removed)`);

  const probe = await fetch(url, { headers });
  const [ok, msg] = interpret(probe.headers.get('cf-cache-status'), probe.headers.get('age'));
  (ok ? console.log : console.error)(msg);
  if (!ok) {
    console.error('try purge by prefix, hostname or tag -- none are affected by custom cache keys');
  }
  process.exit(ok ? 0 : 1);
}

if (import.meta.url === `file://${process.argv[1]}`) main();

Add a test

The subtle case is EXPIRED. With tiered cache it is what a successful purge looks like while the lower tier revalidates, so treating it as a failure would send people chasing a purge that already worked.

test_cloudflare_purge_verify.py
from cloudflare_purge_verify import interpret, purge_will_miss


def test_miss_means_the_purge_worked():
    ok, _ = interpret("MISS", None)
    assert ok


def test_expired_is_success_under_tiered_cache():
    """The lower tier is revalidating against the upper tier. Not a failure."""
    ok, _ = interpret("EXPIRED", "3")
    assert ok


def test_hit_with_a_large_age_is_a_failed_purge():
    ok, msg = interpret("HIT", "86400")
    assert not ok and "cache key" in msg


def test_dynamic_is_not_a_failure():
    ok, _ = interpret("DYNAMIC", None)
    assert ok


def test_a_custom_key_with_headers_blocks_single_file_purge():
    rule = {"cache_key": {"custom_key": {"header": {"include": ["Origin"]}}}}
    assert any("custom cache key" in r for r in purge_will_miss(rule))


def test_a_get_only_expression_is_flagged():
    rule = {"expression": 'http.request.method eq "GET"'}
    assert any("only GET" in r for r in purge_will_miss(rule))


def test_an_expression_that_allows_purge_is_not_flagged():
    rule = {"expression": '(http.request.method eq "GET" or http.request.method eq "PURGE")'}
    assert purge_will_miss(rule) == []


def test_a_plain_rule_is_clean():
    assert purge_will_miss({"expression": 'http.host eq "example.com"'}) == []
cloudflare-purge-verify.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { interpret, purgeWillMiss } from './cloudflare-purge-verify.mjs';

test('MISS means the purge worked', () => {
  assert.equal(interpret('MISS', null)[0], true);
});

test('EXPIRED is success under tiered cache', () => {
  assert.equal(interpret('EXPIRED', '3')[0], true);
});

test('HIT with a large age is a failed purge', () => {
  const [ok, msg] = interpret('HIT', '86400');
  assert.equal(ok, false);
  assert.ok(msg.includes('cache key'));
});

test('a custom key with headers blocks single-file purge', () => {
  const rule = { cache_key: { custom_key: { header: { include: ['Origin'] } } } };
  assert.ok(purgeWillMiss(rule).some((r) => r.includes('custom cache key')));
});

test('an expression that allows PURGE is not flagged', () => {
  const rule = { expression: '(http.request.method eq "GET" or http.request.method eq "PURGE")' };
  assert.deepEqual(purgeWillMiss(rule), []);
});

FAQ

Why does Cloudflare purge return success but not clear anything?

The purge API is idempotent — asking to remove an object that is not there succeeds. If your purge names a cache key that does not exist, the response is identical to a purge that worked. Check CF-Cache-Status instead of the response body.

What is a custom cache key and why does it break purging?

A Cache Rule can index cached objects by more than the URL — headers, cookies, other request properties. A dashboard purge form has nowhere to supply those, so for such objects it structurally cannot work. Use the API with a headers object, or purge by prefix, hostname or tag.

Can I purge a URL with a query string by prefix?

No. Prefix purge ignores query strings and fragments. Purging /bar clears /bar?good=bad, but purging /bar?good=bad directly does not work. Prefix purge is also limited to 100 prefixes per request and 31 path separators.

Why do I see EXPIRED instead of MISS after purging?

That is tiered cache working normally: the lower tier is revalidating against the upper tier to reduce load on it. Depending on which tier the request reaches, either EXPIRED or MISS is correct, and both mean the purge took effect.

The edge is clear but visitors still see the old file. Why?

Their browser cached it under the Cache-Control max-age you sent. Purging Cloudflare does not reach into a browser cache. Fingerprinted filenames are the real fix; a purge never will be.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.