Purging Cloudflare CDN Tile Cache from a Python Pipeline

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.

Purge-after-publish sequence A pipeline builds tiles, uploads them to origin, and only then calls the Cloudflare purge API. The edge marks objects stale and re-fetches fresh tiles from origin on the next browser request. 1. Build tiles Python rebuild job 2. Upload origin tiles now live 3. Purge API POST changed URLs Cloudflare edge marks objects stale Browser / Map next request re-fetch on miss Purge is invalidation only — origin must already hold the new tiles

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 -sI and read the cf-cache-status header — the first request after a purge should report MISS or EXPIRED, and the following request HIT.
  • Verify CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID resolve 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.