Part of the Cache Invalidation Strategies guide.
Operative rule: purge the CDN edge cache only after the new tiles are live at origin — a purge tells the edge to re-fetch on the next request, so if the fresh bytes are not yet written, Cloudflare simply re-caches the stale ones.
How the Purge Handshake Works
When a geo-dashboard serves tiles through Cloudflare, each /tiles/{z}/{x}/{y}.mvt (or .png) response is stored at the edge keyed on its URL. Subsequent requests are answered from the nearest data centre without ever touching your origin. This is exactly what you want for read-heavy map traffic — until a rebuild changes the underlying data. The URL is identical, so the edge keeps serving the bytes it already holds. A purge is the signal that flips a cached object to stale: the next request for that URL forces the edge to revalidate against origin and store the fresh response.
The critical sequencing detail is that a purge does not push new content — it only invalidates. The Cloudflare cache-purge REST API accepts an authenticated POST and returns almost immediately, but the actual re-fetch happens lazily, on the first request for each purged URL after the call succeeds. If your pipeline purges before the origin has the new tiles, a user (or Cloudflare’s own revalidation) can trigger a re-fetch of the old bytes, re-populating the edge with stale content under the same URL. That is why the purge must be the final step, gated on a successful origin upload. This step slots into your broader Cache Invalidation Strategies and complements versioning tile URLs with content hashes, which handles the browser layer the edge purge cannot reach.
Production-Ready Implementation
The client below is designed to run as the last step of a rebuild job. It reads its credentials from environment variables (never hard-code a token), batches URL purges into the 30-URL limit the endpoint enforces, and exposes three purge modes: explicit URL list, prefix, and cache-tag. The base URL and zone identifier come from configuration so the same client works across staging and production zones.
# cloudflare_purge.py — final pipeline step: invalidate changed tiles at the edge
# Requires: requests (pip install requests). Never runs before the origin upload.
from __future__ import annotations
import os
import time
from collections.abc import Iterable, Sequence
import requests
API_BASE = "https://api.cloudflare.com/client/v4"
MAX_URLS_PER_REQUEST = 30 # hard limit of the purge-by-URL endpoint
RATE_LIMIT_BACKOFF_S = 2.0
class CloudflarePurger:
"""Purge map tiles from the Cloudflare edge cache after a rebuild."""
def __init__(self) -> None:
# Credentials come from the environment — keep tokens out of source.
self.zone_id: str = os.environ["CLOUDFLARE_ZONE_ID"]
token: str = os.environ["CLOUDFLARE_API_TOKEN"]
self.session = requests.Session()
self.session.headers.update(
{
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
)
def _endpoint(self) -> str:
return f"{API_BASE}/zones/{self.zone_id}/purge_cache"
def _post(self, payload: dict) -> None:
"""POST a purge payload, retrying once on a rate-limit (429) response."""
for attempt in range(2):
resp = self.session.post(self._endpoint(), json=payload, timeout=30)
if resp.status_code == 429: # rate limited — back off and retry
time.sleep(RATE_LIMIT_BACKOFF_S * (attempt + 1))
continue
body = resp.json()
if not body.get("success", False):
raise RuntimeError(f"Purge failed: {body.get('errors')}")
return
raise RuntimeError("Purge rate-limited after retry")
@staticmethod
def _chunk(items: Sequence[str], size: int) -> Iterable[Sequence[str]]:
for i in range(0, len(items), size):
yield items[i : i + size]
def purge_urls(self, urls: Sequence[str]) -> int:
"""Purge an explicit list of absolute tile URLs, batched to the API limit."""
batches = 0
for chunk in self._chunk(urls, MAX_URLS_PER_REQUEST):
self._post({"files": list(chunk)})
batches += 1
return batches
def purge_prefix(self, prefixes: Sequence[str]) -> None:
"""Purge every cached object under a path prefix (Enterprise feature)."""
# e.g. ["cdn.example.com/tiles/v42/"] invalidates a whole tile version tree.
self._post({"prefixes": list(prefixes)})
def purge_tags(self, tags: Sequence[str]) -> None:
"""Purge by Cache-Tag — tiles tagged at origin with `Cache-Tag: tiles-<layer>`."""
self._post({"tags": list(tags)})
def build_changed_tile_urls(
base: str, version: str, changed_xyz: Iterable[tuple[int, int, int]]
) -> list[str]:
"""Map a set of changed (z, x, y) coordinates to absolute, versioned tile URLs."""
return [
f"{base}/tiles/{version}/{z}/{x}/{y}.mvt" for (z, x, y) in changed_xyz
]
if __name__ == "__main__":
# These come from the delta step earlier in the pipeline.
changed = [(12, 2103, 1345), (12, 2104, 1345), (13, 4206, 2690)]
purger = CloudflarePurger()
tile_urls = build_changed_tile_urls(
base="https://cdn.example.com", version="va3f9c2", changed_xyz=changed
)
# Purge only what actually changed — targeted purges are faster and cheaper.
sent = purger.purge_urls(tile_urls)
print(f"Purged {len(tile_urls)} tile URLs in {sent} batch(es).")
Alternative Variants
Purge everything (use sparingly)
When a rebuild touches so many tiles that enumerating them is impractical, a full-zone purge is available. It invalidates all cached objects in the zone — CSS, fonts, and the map bundle included — so the edge cold-starts and origin load spikes. Reserve it for schema-level changes, not routine data refreshes.
def purge_everything(purger: CloudflarePurger) -> None:
"""Nuclear option: invalidate the entire zone. Spikes origin load — avoid routine use."""
purger._post({"purge_everything": True})
Choosing a purge mode
| Mode | Payload key | Best for | Limit / caveat |
|---|---|---|---|
| By URL | files |
Small, known change sets | 30 URLs per request |
| By prefix | prefixes |
A whole tile-version tree | Enterprise plan only |
| By cache-tag | tags |
Logical groups (per layer) | Requires Cache-Tag at origin; Enterprise |
| Everything | purge_everything |
Schema changes, emergencies | Cold-starts the entire zone |
For most incremental dashboards, purge-by-URL driven from the delta set is the right default: it invalidates exactly the tiles that changed and nothing else. When you version tile paths, purge-by-prefix on the old version directory becomes an elegant one-call cleanup.
Verification Steps
- Confirm the purge runs after the origin upload — inspect the pipeline logs and check the purge timestamp is later than the upload-complete timestamp.
- Assert the API response body contains
"success": true; treat any other result as a pipeline failure, not a warning. - Request a purged tile with
curl -sIand read thecf-cache-statusheader — the first request after a purge should reportMISSorEXPIRED, and the following requestHIT. - Verify
CLOUDFLARE_API_TOKENandCLOUDFLARE_ZONE_IDresolve from the environment in the runner (env | grep CLOUDFLARE), not from a checked-in file. - Cross-check that a browser hard-refresh shows the new tiles; if not, the edge purged correctly but the browser cache is the culprit — pair this with versioned URLs.
Common Errors & Fixes
Purge returns success: true but stale tiles persist at the edge
The purge fired before the origin upload finished, so the edge re-fetched and re-cached the old bytes on the next request. Move the purge call to the very end of the pipeline and gate it on the upload step’s success. If your upload is eventually consistent (for example an object store that takes seconds to propagate), add a short readiness check that fetches one representative tile from origin and compares its content hash before purging.
Authorization header rejected with error code 10000
The token is missing, malformed, or lacks the Zone → Cache Purge permission. Confirm the environment variable is populated in the CI runner — a common failure is a secret defined at the repository level but not exposed to the job. The token must be scoped to the specific zone; a token for a different zone returns an authentication error even though it is otherwise valid.
HTTP 429 Too Many Requests during large purges
You exceeded the per-zone purge rate limit by sending batches back-to-back. The client above retries once with linear backoff; for very large change sets, add jitter and widen the delay, or switch to purge-by-prefix so a single call replaces hundreds of URL batches. Enumerating tens of thousands of URLs is the wrong tool — prefix or cache-tag purges scale far better.
Some changed tiles never refresh even after purging their URLs
The URLs you purged do not byte-for-byte match the cached keys. Cloudflare keys on the full URL including scheme, host, and any query string. If your tiles are served with a trailing ?v= parameter, or over both http and https, purge the exact URL variants the map actually requests. When in doubt, capture the real request URL from DevTools and purge that literal string.
Related
- Cache Invalidation Strategies — parent guide covering edge, browser, and service-worker invalidation for geo-dashboards
- Versioning Tile URLs with Content Hashes for Cache-Busting — the browser-layer complement to an edge purge, using immutable versioned paths
- Clearing Browser Tile Cache After Python Data Updates — invalidate the client cache the edge purge cannot reach
- Scheduled Map Rebuild Workflows — where the purge step lives in a recurring rebuild job