Cache Invalidation Strategies for Geo-Dashboards
Part of the Data Refresh & Automation Pipelines guide.
In automated web mapping, stale spatial data erodes user trust faster than any rendering artifact. When underlying geospatial datasets update, the entire delivery stack — from origin tile servers to edge CDN nodes and client-side map engines — must synchronise. This guide covers production-tested cache invalidation patterns for raster tiles, vector tilesets, and dashboard payloads across modern geospatial pipelines. It is aimed at full-stack developers and GIS engineers who already manage a scheduled map rebuild workflow or event-driven refresh system and need to ensure that end users always see current data without sacrificing the performance gains of aggressive caching.
Invalidation Scope Diagram
The diagram below shows the three independent cache layers a geospatial pipeline must coordinate. A purge that reaches only the CDN edge still leaves browsers and service workers serving stale tiles.
Prerequisites
Before wiring up programmatic invalidation, confirm that your infrastructure meets these baseline requirements:
- CDN with a tag-based purge API — your edge provider must support
Cache-TagorSurrogate-Key - Consistent tile URL schema — tile endpoints must follow a predictable path such as
/tiles/{layer}/{z}/{x}/{y}.pbf - Dataset freshness token — a canonical source of truth (Git commit hash, database
updated_at - Origin
Cache-TagorSurrogate-Keyresponse headers - Telemetry on cache hit ratios —
X-Cacheorcf-cache-status - HTTP caching semantics — working knowledge of
Cache-Control,ETag, andVarydirectives. The tile vs vector rendering strategy
Step 1: Detect Dataset Change
Monitor your geospatial data store — PostGIS, S3, GeoPackage, or a streaming source — for schema or content changes. Incremental processing pipelines typically emit a manifest file or database trigger when new features are ingested.
For time-sensitive layers, integrate webhook-triggered updates to bypass polling latency and immediately notify the cache orchestration layer. Detection must capture both spatial extent changes and attribute modifications, because both affect tile rendering. A feature geometry update may invalidate only a narrow bounding box of tiles, whereas an attribute rename may require a full style recompile and layer-wide purge.
# detect_change.py — poll PostGIS updated_at and emit a purge manifest
import json
import psycopg2
from datetime import datetime, timezone
def build_purge_manifest(dsn: str, layer: str, since: datetime) -> dict:
"""Return affected tile coords and cache tags for rows updated after `since`."""
with psycopg2.connect(dsn) as conn, conn.cursor() as cur:
cur.execute(
"""
SELECT
ST_XMin(env) AS xmin, ST_YMin(env) AS ymin,
ST_XMax(env) AS xmax, ST_YMax(env) AS ymax
FROM (
SELECT ST_Extent(geom) AS env
FROM public.%s
WHERE updated_at > %%s
) sub
""",
(layer, since),
)
row = cur.fetchone()
if row is None or row[0] is None:
return {"layer": layer, "changed": False}
return {
"layer": layer,
"changed": True,
"bbox": {"xmin": row[0], "ymin": row[1], "xmax": row[2], "ymax": row[3]},
"tags": [f"layer:{layer}", f"updated:{since.date().isoformat()}"],
}
Step 2: Determine Invalidation Scope
Classify the update to avoid over-purging (which causes origin thundering herds) or under-purging (which serves outdated basemaps or choropleth values to users):
- Full layer rebuild — entire tileset regenerated. Requires a prefix or tag purge covering all zoom levels.
- Incremental bounding-box patch — only tiles intersecting the changed features need flushing. Use PostGIS
ST_TileEnvelopeat each relevant zoom level to enumerate affected{z}/{x}/{y}coordinates. - Metadata or dashboard config update — only JSON payloads or style specifications changed. A single-URL purge or
ETagrotation is sufficient; the tile grid itself is still valid.
This classification directly determines cost and user experience. A bounding-box patch on a single municipality boundary update might touch fewer than 200 tiles at z=12–z=14, whereas a full administrative reclassification might invalidate millions. Always compute scope before calling the purge API.
# scope.py — enumerate affected tile XYZ coords from a WGS-84 bbox
import math
def lon_to_tile_x(lon: float, zoom: int) -> int:
return int((lon + 180.0) / 360.0 * (2 ** zoom))
def lat_to_tile_y(lat: float, zoom: int) -> int:
lat_rad = math.radians(lat)
n = 2 ** zoom
return int((1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi) / 2.0 * n)
def tiles_in_bbox(
xmin: float, ymin: float, xmax: float, ymax: float, zooms: list[int]
) -> list[str]:
"""Return a list of 'z/x/y' strings for all tiles covering the bbox."""
urls: list[str] = []
for z in zooms:
x0, x1 = lon_to_tile_x(xmin, z), lon_to_tile_x(xmax, z)
y0, y1 = lat_to_tile_y(ymax, z), lat_to_tile_y(ymin, z) # y flipped
for x in range(x0, x1 + 1):
for y in range(y0, y1 + 1):
urls.append(f"{z}/{x}/{y}")
return urls
Step 3: Execute the CDN Purge
Once scope is defined, call the CDN purge API. Tag-based purging is far more efficient than per-URL removal — a single API call can invalidate every tile tagged layer:flood_zones across all edge nodes simultaneously. Attach these tags at the origin response level using Cache-Tag (Cloudflare) or Surrogate-Key (Fastly/Varnish).
# purge.py — tag-based purge via Cloudflare Cache Purge API
import os
import requests
def purge_by_tags(tags: list[str]) -> bool:
"""Submit a tag-based cache purge. Returns True on success."""
zone_id = os.environ["CF_ZONE_ID"]
token = os.environ["CF_API_TOKEN"]
response = requests.post(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json={"tags": tags},
timeout=15,
)
result = response.json()
if not result.get("success"):
raise RuntimeError(f"Purge failed: {result.get('errors')}")
return True
For environments using scheduled map rebuild workflows, this purge step should run as a post-processing hook immediately after tile generation completes — never before, because a premature purge followed by a slow rebuild leaves users staring at 404 tile gaps.
Alternatively, for incremental patch scenarios where only a known tile list changes, submit URL-based purges in batches of 30 (Cloudflare’s per-request limit):
def purge_urls(base_url: str, layer: str, tile_coords: list[str]) -> None:
"""Purge a list of tile URLs in batches of 30."""
zone_id = os.environ["CF_ZONE_ID"]
token = os.environ["CF_API_TOKEN"]
urls = [f"{base_url}/tiles/{layer}/{coord}.pbf" for coord in tile_coords]
for i in range(0, len(urls), 30):
batch = urls[i : i + 30]
requests.post(
f"https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"files": batch},
timeout=15,
)
Step 4: Coordinate Client-Side Caches
Server-side invalidation only clears the CDN edge. Browsers aggressively cache map tiles based on the Cache-Control headers they received, and service workers often intercept tile requests before they even reach the network. Uncoordinated client-side caches mean users may continue seeing stale tiles for hours after a successful CDN purge.
Three coordinated mechanisms are needed:
URL versioning via tileVersion query parameter — append the pipeline run ID or dataset hash to all tile URLs in your map initialisation code. When the dataset updates, the frontend receives a new version token (via a lightweight /api/tile-version endpoint), updates the tile URL template, and the browser treats the new URL as an uncached resource. This avoids the need for any client-side cache flushing logic and is the approach most reliably compatible with clearing browser tile caches after Python data updates.
// map-init.js — poll for version drift and hot-swap the tile layer
async function checkTileVersion(map, currentLayer, currentVersion) {
const res = await fetch("/api/tile-version");
const { version } = await res.json();
if (version !== currentVersion) {
map.removeLayer(currentLayer);
const fresh = L.tileLayer(
`/tiles/flood_zones/{z}/{x}/{y}.pbf?v=${version}`,
{ maxZoom: 16 }
).addTo(map);
return [fresh, version];
}
return [currentLayer, currentVersion];
}
// Poll every 60 s; swap the layer without a full page reload
setInterval(() => checkTileVersion(map, activeLayer, tileVersion), 60_000);
Service worker cache broadcast — if your dashboard registers a service worker for offline fallback, send a postMessage event to flush its tile cache when a new version is detected:
// version-check.js — signal the service worker to clear stale tiles
if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
type: "CACHE_INVALIDATE",
layer: "flood_zones",
version: newVersion,
});
}
// service-worker.js — respond to invalidation broadcast
self.addEventListener("message", (event) => {
if (event.data?.type === "CACHE_INVALIDATE") {
const { layer, version } = event.data;
caches.open("tile-cache-v1").then((cache) => {
cache.keys().then((keys) =>
keys
.filter((req) => req.url.includes(`/tiles/${layer}/`))
.forEach((req) => cache.delete(req))
);
});
}
});
Health-check endpoint for drift detection — expose a lightweight endpoint that returns the current dataset hash. The frontend polls this on a configurable interval and triggers the version swap only when drift is detected, minimising unnecessary tile layer replacements:
# routes.py (FastAPI example)
from fastapi import APIRouter
import hashlib, os
router = APIRouter()
@router.get("/api/tile-version")
async def tile_version() -> dict[str, str]:
"""Return the current tile dataset hash for client-side drift detection."""
pipeline_run_id = os.environ.get("PIPELINE_RUN_ID", "unknown")
version_hash = hashlib.sha1(pipeline_run_id.encode()).hexdigest()[:8]
return {"version": version_hash}
Step 5: Verify and Monitor
After issuing the purge, validate that edge nodes have accepted the request and that subsequent client requests bypass stale entries. Use curl -I to inspect response headers:
# First request after purge — expect a cache MISS
curl -I "https://cdn.example.com/tiles/flood_zones/12/1024/680.pbf"
# Second request — expect a cache HIT, confirming the edge is warm again
curl -I "https://cdn.example.com/tiles/flood_zones/12/1024/680.pbf"
Look for X-Cache: MISS or cf-cache-status: DYNAMIC on the first request post-purge, followed by X-Cache: HIT on the next. Integrate this check into your deployment pipeline to fail fast if propagation exceeds your SLA threshold. Also confirm that Cache-Tag response headers match the tags you purged — a mismatch means future purges will not reach these tiles.
Track these signals in your monitoring stack:
- Purge latency — time from API call to first
MISSon a sampled tile URL. - Origin request spike — a sharp spike immediately after purge is normal; a sustained spike suggests
stale-while-revalidateis not configured. - Cache hit ratio by layer — a low hit ratio on a layer that rarely changes indicates either over-purging or a
Cache-Control: no-storemisconfiguration at the origin.
URL Versioning and Stale-While-Revalidate
Two complementary tactics reduce the cost and risk of invalidation in production:
URL versioning embeds the version identifier directly in the tile path: /tiles/roads/v3.2/{z}/{x}/{y}.pbf. When a new dataset drops, the application switches to v3.3. Old URLs remain cached until their max-age expires, eliminating the need for any CDN purge call. This is the highest-reliability option for stable layers that update on a predictable schedule, and it integrates naturally with the incremental data processing patterns used to manage dataset versions.
stale-while-revalidate allows edge nodes to serve slightly outdated tiles while asynchronously fetching fresh ones:
Cache-Control: public, max-age=3600, stale-while-revalidate=86400, stale-if-error=172800
This is especially valuable for vector tilesets where a mid-purge window might otherwise cause a client to receive a mix of old and new tiles at adjacent zoom levels. Configure your origin to return 304 Not Modified using ETag or Last-Modified when tile content has not changed, preserving bandwidth while maintaining cache coherence.
Troubleshooting
Why do my tiles still look stale after the CDN purge succeeds?
The CDN purge only clears the edge. The browser has its own cache keyed to the exact URL (including query parameters). If your tile URLs have not changed and the browser’s Cache-Control max-age has not expired, the browser will not re-fetch from the CDN. Fix: increment the tileVersion query parameter in your map initialisation config whenever you issue a CDN purge. This makes the URL unique and forces a fresh fetch.
Why is my origin getting hammered immediately after every purge?
A CDN purge causes a thundering herd if many concurrent users request the same tile simultaneously and the CDN has no stale copy to serve during revalidation. Fix: add stale-while-revalidate=86400 to the Cache-Control header so the CDN can serve the previous (slightly stale) tile while a single background request fetches the fresh version. Also consider staggering the purge across zoom levels if the tileset is very large.
My service worker is serving tiles that are months old — CDN and browser cache are both fresh. Why?
Service workers intercept requests before they reach the browser cache. If the service worker’s fetch handler has stored tiles in a Cache object without an expiry or version check, it will serve those indefinitely. Fix: send a postMessage({type: "CACHE_INVALIDATE"}) broadcast after the CDN purge and update the service worker’s install handler to version its cache name (e.g. tile-cache-v2) so old caches are deleted on activation.
Cache-Tag purges work in staging but not in production — why?
The most common cause is that Cache-Tag headers are being stripped by an intermediate proxy or the CDN is not configured to index responses by that header. Verify: curl -I a tile URL in production and confirm the Cache-Tag header is present in the response. On Cloudflare, Cache-Tag support requires the Business plan or above; on lower plans, use cf-cache-status and URL-based purges instead.
My partial bounding-box purge causes topology mismatches at tile boundaries.
Purging only z=12 while z=13 remains cached causes visible seam artefacts when the map zooms across that boundary. Fix: always purge by layer tag across all zoom levels, or include a ±1 tile buffer around the bounding box to account for geometry that straddles tile edges.
Why does no-cache in Cache-Control not seem to prevent caching?
no-cache does not mean “do not cache” — it means “always revalidate before serving from cache”. If the origin returns a 304 Not Modified, the cached copy is served anyway. To prevent caching entirely, use no-store. For tiles that must always be fresh but can still be stored for offline use, no-cache, must-revalidate is the correct combination.
Gotchas and Edge Cases
- Purging by URL prefix vs by tag — URL-prefix purges are often limited to a single path segment per API call, making them impractical for
z/x/ytile grids. Always prefer tag-based purging for tile layers. Varyheader bloat — addingVary: Accept-Encoding, Accept-Languageto tile responses fragments the CDN cache into many separate entries, multiplying the number of URLs that must be purged. MinimiseVaryheaders on tile endpoints; they rarely need to vary by anything other thanAccept-Encoding.- CDN API rate limits — bulk URL purges consume per-minute quota. Group tags logically and batch requests. One
Cache-Tagpurge call replaces thousands of individual URL purge calls. - Zoom-level asymmetry — vector tilesets share geometry across zoom levels via overzoom rendering. Invalidating only high zoom levels while leaving low-zoom overview tiles cached can cause inconsistent choropleth values between zoom transitions. Purge by layer tag, not by zoom prefix.
stale-if-errorand offline dashboards — setting a longstale-if-errorwindow (e.g. 48 hours) is valuable for resilience during origin outages, but means users may see very stale data if both the origin and the background revalidation fail silently. Log revalidation failures explicitly.- ETag mismatch after gzip toggling — enabling or disabling gzip compression at the origin changes the response body and therefore the
ETag. If CDN nodes hold copies with a gzip-basedETagand the origin starts serving uncompressed responses, all conditional requests will result in200instead of304, increasing bandwidth. Normalise compression settings before rotatingETagvalues. - Iframe-embedded dashboards — if the map is served inside an
<iframe>for iframe embedding isolation, the browser may apply separate cache partitioning per origin, meaning a parent-page version bump does not propagate into the iframe’s cache partition. ThetileVersionparameter must be embedded in the iframesrcURL directly.
Related
- Data Refresh & Automation Pipelines — parent guide covering the full pipeline orchestration context
- Scheduled Map Rebuild Workflows — run nightly or CI-triggered rebuilds that feed the purge workflow
- Webhook-Triggered Updates — event-driven change detection that eliminates polling lag before purge
- Incremental Data Processing — version and diff datasets to narrow invalidation scope to changed features only
- Clearing Browser Tile Cache After Python Data Updates — deep-dive on the client-side coordination piece of the invalidation chain