Debugging Stale Tiles in CDN-Backed Map Servers

A tile is stale because the edge served a cached copy whose key was never purged: the rendered bytes at the origin changed, but the CDN is still handing clients an object it stored earlier under a key your invalidation logic never touched. Debugging it is a four-part routine — inspect the response Age, Cache-Control, and ETag headers to confirm the copy really is old and cached; confirm the renderer and the CDN agree on the cache key (including any style or version token); verify the purge covered the exact z/x/y set the data edit touched; and clear any negative-cached 404 left over from before the data existed.

Core Diagnostic Workflow

Treat a stale tile as a key-tracing problem, not a rendering problem. The bytes at the origin are almost always correct — the failure is that the client and the CDN disagree about which stored object corresponds to the current data. The decision tree below walks a single stale coordinate from symptom to root cause.

Stale-tile debugging decision tree A branching flowchart. Start: tile looks old. First check the Age header. If Age is zero the origin render is stale, so rebuild the tile. If Age is non-zero, compare the client cache key with the CDN cache key. A mismatch means purge the correct key; a match leads to checking purge coverage of the z/x/y set. Missing coordinates mean re-purge the full set; full coverage leads to checking for a negative-cached 404, whose fix is to purge coordinates and shorten the negative TTL. Tile looks old at the edge Read Age header Age = 0 ? yes Origin render stale: rebuild no Client key == CDN key ? (path + version token) mismatch Purge the key clients request match Purge covered every edited z/x/y ? gap Re-purge full tile set yes Negative-cached 404 ? (added after first miss) yes Purge coords + shorten negative TTL

The four checks map directly onto the four ways an edge cache and an origin fall out of sync:

  1. Header inspection. A non-zero Age header proves the edge returned a stored copy, and its value is how many seconds old that copy is. Cache-Control tells you the max-age the object was stored under, and ETag lets you match the served bytes against a specific render. If Age is 0, the edge already went to origin and the stale content is coming from the renderer itself — stop debugging the CDN and rebuild the tile.
  2. Cache-key reconciliation. The CDN computes a key from the request. If that key includes a style or version token, or a normalised query string, but your purge routine targets the bare z/x/y path, you purge an object no client ever requests. This is the single most common cause of a purge that “did nothing.”
  3. Purge coverage. A data edit rarely touches one tile. Confirm you enumerated every z/x/y the edited features fall in — across every zoom the layer is served at — and that the purge request actually listed them all. Overzoomed vector layers also need the parent tiles that get overzoomed on the client.
  4. Negative-cache clearance. If a client requested a coordinate before the data existed, the edge may have stored the 404. Those coordinates keep returning the cached error until the negative-TTL expires, independent of any successful render at origin.

This routine slots into any tile pipeline built on the patterns in Tile Cache and Invalidation, and it applies equally to raster and vector outputs produced by vector tile generation.

Production-Ready Python Implementation

The script below is a complete diagnostic. It fetches a tile from the edge, reports the cache-relevant headers, derives the effective cache key the way a token-aware CDN would, enumerates the z/x/y set a bounding box touches at a range of zooms (so you can verify purge coverage), and probes for negative-cached 404s. It uses requests 2.32 and mercantile 1.2 for tile math; both are standard in server-side tile work.

import math
import requests
import mercantile  # pip install mercantile==1.2.1 requests==2.32.3

CACHE_STATUS_HEADERS = (
    "cache-status", "cf-cache-status", "x-cache", "x-cache-status", "age",
)


def inspect_tile(tile_url: str, timeout: float = 10.0) -> dict:
    """
    Fetch a single tile and return the headers that decide staleness.

    A non-zero `Age` means the edge served a stored copy that is `Age`
    seconds old. `ETag`/`Last-Modified` identify *which* render you got,
    so you can match served bytes against the current origin output.
    """
    resp = requests.get(tile_url, timeout=timeout)
    hdr = {k.lower(): v for k, v in resp.headers.items()}
    return {
        "url": tile_url,
        "status": resp.status_code,
        "age": int(hdr.get("age", 0)),
        "cache_control": hdr.get("cache-control", ""),
        "etag": hdr.get("etag", ""),
        "last_modified": hdr.get("last-modified", ""),
        "cache_status": {h: hdr[h] for h in CACHE_STATUS_HEADERS if h in hdr},
        "content_length": int(hdr.get("content-length", 0)),
    }


def effective_cache_key(path: str, version_token: str | None,
                        cache_query_params: tuple[str, ...] = ()) -> str:
    """
    Reproduce the key a token-aware CDN stores under.

    If the renderer bakes a style/version token into the path (or the CDN
    is configured to vary on `?v=`), a purge that omits the token targets
    an object no client ever requests -- the classic 'purge did nothing'.
    """
    key = path if version_token is None else f"{path}?v={version_token}"
    # Sort whitelisted query params so key derivation is order-independent,
    # mirroring how CDNs normalise the query string before hashing.
    if cache_query_params:
        parts = "&".join(sorted(cache_query_params))
        key = f"{key}{'&' if '?' in key else '?'}{parts}"
    return key


def tiles_for_bbox(bbox: tuple[float, float, float, float],
                   min_zoom: int, max_zoom: int) -> list[mercantile.Tile]:
    """
    Enumerate every z/x/y a data edit touches, so you can confirm the
    purge covered all of them. `bbox` is (west, south, east, north) in
    EPSG:4326. Missing even one tile here is a silent purge-coverage gap.
    """
    tiles: list[mercantile.Tile] = []
    for zoom in range(min_zoom, max_zoom + 1):
        tiles.extend(mercantile.tiles(*bbox, zooms=zoom))
    return tiles


def probe_negative_cache(url_template: str,
                         tiles: list[mercantile.Tile]) -> list[dict]:
    """
    Detect negative-cached 404s: a coordinate that 404s at the edge with a
    non-zero Age was cached as an error before the data existed. Those need
    an explicit purge -- a successful origin render will not dislodge them.
    """
    stuck = []
    for t in tiles:
        info = inspect_tile(url_template.format(z=t.z, x=t.x, y=t.y))
        if info["status"] == 404 and info["age"] > 0:
            stuck.append(info)
    return stuck


def diagnose(url_template: str, version_token: str,
             bbox: tuple[float, float, float, float],
             min_zoom: int, max_zoom: int,
             fresh_max_age: int = 600) -> None:
    """
    Run the full stale-tile routine over an edited bounding box and print a
    verdict per tile: FRESH, STALE (needs purge), or NEGATIVE-CACHED 404.
    """
    tiles = tiles_for_bbox(bbox, min_zoom, max_zoom)
    print(f"edit touches {len(tiles)} tiles across z{min_zoom}-{max_zoom}")

    for t in tiles:
        path = f"/tiles/{t.z}/{t.x}/{t.y}.pbf"
        key = effective_cache_key(path, version_token)
        info = inspect_tile(url_template.format(z=t.z, x=t.x, y=t.y))

        if info["status"] == 404 and info["age"] > 0:
            verdict = "NEGATIVE-CACHED 404 -> purge coords, shorten neg-TTL"
        elif info["age"] > fresh_max_age:
            verdict = f"STALE (Age={info['age']}s) -> purge key {key}"
        else:
            verdict = f"FRESH (Age={info['age']}s)"
        print(f"  z{t.z}/{t.x}/{t.y}  {verdict}")


if __name__ == "__main__":
    # Bounding box of the edited feature set, WGS84 (west, south, east, north).
    edit_bbox = (-73.99, 40.71, -73.95, 40.75)
    diagnose(
        url_template="https://tiles.example.com/tiles/{z}/{x}/{y}.pbf?v=build-8f2c",
        version_token="build-8f2c",
        bbox=edit_bbox,
        min_zoom=12,
        max_zoom=16,
    )

The diagnose output is the artefact you attach to a bug report: it names every coordinate the edit touched and labels each as fresh, stale, or negative-cached, so the fix (a targeted purge or a token bump) is unambiguous rather than a blanket “purge everything.”

Header-Inspection and Purge-Verification Best Practices

  • Read Age before touching anything. Age is the ground truth for “did the edge serve a stored copy.” If Age is 0 on a tile that still looks wrong, the CDN is innocent — the renderer produced stale bytes and no purge will help. This single check separates cache bugs from render bugs and saves most of the debugging time.
  • Reconcile the key, don’t guess it. Pull the CDN’s cache-key configuration and confirm whether it varies on a version token or query string, then derive the same key with effective_cache_key. Purging the bare path when clients request ?v=build-8f2c is the archetypal purge that reports success and changes nothing.
  • Enumerate the tile set from the geometry, not by eye. Use mercantile.tiles(*bbox, zooms=z) across the full zoom range the layer is served at. Coverage gaps hide at the edges of an edit and at high zooms, where one polygon spans dozens of tiles. Overzoomed vector layers additionally need their parent tiles purged.
  • Prefer an immutable version token over a short max-age. A short max-age forces constant revalidation and still serves stale bytes inside the window; an immutable token (Cache-Control: max-age=31536000, immutable) lets you cache hard and invalidate by changing the token in the tile URL. The token also functions as a style version, which pairs cleanly with theme systems like cascading theme overrides for multi-tenant web maps.
  • Send Cache-Control: no-store on 404s, or set a short negative-TTL. Coverage gaps are temporary by nature — data lands after the first request. Letting the edge cache those 404s for hours turns a transient miss into a frozen hole that only an explicit coordinate purge clears.

Integration and Next Steps

The diagnostic emits per-tile verdicts, which makes it easy to wire into the rest of a tile pipeline:

  • Post-edit hook. Run diagnose immediately after a data edit lands, keyed on the edit’s bounding box, and fail the deploy if any tile in the set is still STALE after the purge. This turns “did the purge work” from a manual spot-check into an automated gate.
  • Token-bump on rebuild. When you regenerate tiles, bump the version_token in the URL template so clients request a fresh key. Re-running inspect_tile should then show a MISS followed by a HIT against the new bytes — the clean signal that invalidation succeeded.
  • Zoom-scale awareness. Because coverage depends on how many tiles a feature spans, the zoom range you pass to tiles_for_bbox should match how the layer is served; the relationship between web zoom levels and cartographic scale is covered in scale mapping for web and print.
  • Upstream generation. If tiles are consistently stale at origin (Age=0 but wrong), the fault is in the build step rather than the cache — revisit the vector tile generation stage that produces the .pbf bytes before the CDN ever sees them.

Run the full routine any time a map “won’t update” and you will nearly always find one of four culprits: a key the purge never touched, a coverage gap in the z/x/y set, a 404 frozen in the negative cache, or — least often — bytes that were already stale at origin.


Frequently Asked Questions

Why does a tile stay stale even after I purge the CDN?

Almost always a cache-key mismatch. The CDN stores the object under a key that includes a style or version token (or a query string it did not strip), while your purge targets the bare z/x/y path. Purging a key that no client requests leaves the real object untouched, so the edge keeps serving it. Reconcile the CDN’s cache-key definition with the exact URL clients request — reproduce it with effective_cache_key — then purge that key.

How do I tell whether a tile came from the edge cache or the origin?

Read the headers. A non-zero Age means the edge served a stored copy that is Age seconds old. Most CDNs also emit a cache-status header (x-cache, cf-cache-status, or the standard Cache-Status) reading HIT or MISS. If Age is large and the ETag matches the previous render, the edge is holding a stale object and the purge either missed the key or never ran. If Age is 0, the response came from origin and the staleness is a render bug.

Why does a newly added tile keep returning 404 after the data is loaded?

The edge negative-cached the 404 from before the data existed. CDNs cache error responses for a negative-TTL, so requests keep receiving the stored 404 until it expires — a successful origin render will not dislodge it. Purge those specific coordinates and set a short negative-TTL, or send Cache-Control: no-store on 404 responses from the renderer, so coverage gaps self-heal instead of freezing.

Should stale tiles use a short max-age or an explicit version token?

Prefer an immutable version token in the path or query. A short max-age forces every client to revalidate constantly, raising origin load while still serving stale bytes inside the window. An immutable token (a data hash or build id) lets you cache aggressively with Cache-Control: max-age=31536000, immutable and invalidate by changing the token, which sidesteps purge-coverage problems entirely.


  • Tile Cache and Invalidation — the parent overview covering cache-key design, purge strategies, and invalidation triggers across a tile server.
  • Vector Tile Generation — where the .pbf bytes are produced; the place to look when a tile is stale at origin rather than at the edge.
  • Scale Mapping for Web and Print — the zoom-to-scale relationship that determines how many tiles a given edit spans, and therefore your purge coverage.

Back to Tile Cache and Invalidation