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.
The four checks map directly onto the four ways an edge cache and an origin fall out of sync:
- Header inspection. A non-zero
Ageheader proves the edge returned a stored copy, and its value is how many seconds old that copy is.Cache-Controltells you themax-agethe object was stored under, andETaglets you match the served bytes against a specific render. IfAgeis0, the edge already went to origin and the stale content is coming from the renderer itself — stop debugging the CDN and rebuild the tile. - 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/ypath, you purge an object no client ever requests. This is the single most common cause of a purge that “did nothing.” - Purge coverage. A data edit rarely touches one tile. Confirm you enumerated every
z/x/ythe 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. - 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
Agebefore touching anything.Ageis the ground truth for “did the edge serve a stored copy.” IfAgeis0on 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-8f2cis 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 shortmax-ageforces 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-storeon404s, or set a short negative-TTL. Coverage gaps are temporary by nature — data lands after the first request. Letting the edge cache those404s 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
diagnoseimmediately after a data edit lands, keyed on the edit’s bounding box, and fail the deploy if any tile in the set is stillSTALEafter 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_tokenin the URL template so clients request a fresh key. Re-runninginspect_tileshould then show aMISSfollowed by aHITagainst 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_bboxshould 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=0but wrong), the fault is in the build step rather than the cache — revisit the vector tile generation stage that produces the.pbfbytes 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.
Related
- 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
.pbfbytes 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