Edge Caching and Tile Delivery Topology
A tile that is served from cache costs nothing and can be wrong; the whole design problem is deciding, per layer, which of those two properties matters more.
Tile traffic is the highest-volume path in a spatial estate by a wide margin, and it is also the most cacheable — the same tiles are requested repeatedly, they are immutable between republishes, and the working set is heavily concentrated. A cache layer that exploits that turns a renderer sized for peak demand into one sized for cache misses, which is typically an order of magnitude cheaper. It also introduces the estate’s most consequential correctness risk: a stale tile is indistinguishable from a current one, and a consumer acting on an outdated road network or flood extent has no way to know. This topic sits within Federated Ownership & Routing Architecture and specifies how tile caching is keyed, invalidated, and layered so that both properties are governed rather than assumed. It depends directly on the routing discipline in Cross-Domain Routing Strategies, because a cache in front of an ambiguous route caches the ambiguity.
Architectural Boundaries & Design Rationale
The first boundary is between immutable and mutable tile addresses, and choosing which one a layer uses determines every other decision.
An immutable address embeds the product version in the tile URL: /parcels/v1.2.0-crs:EPSG:3857-res:10m/12/2048/1361.mvt. Because a version is never republished under the same identifier, the tile at that address can never change, which permits effectively infinite cache lifetimes at every layer, no invalidation machinery, and no staleness risk at all. The cost is that consumers must discover the current version before requesting tiles, which adds a lookup — usually a small, highly cacheable metadata request — and means a client holding a stale version continues receiving correct-but-old tiles until it re-resolves.
A mutable address omits the version: /parcels/12/2048/1361.mvt always means “current”. Consumers need no lookup and always get the latest data, but every republish requires purging or revalidating a potentially enormous number of cached objects, and any gap in that purge is silent staleness.
| Property | Immutable address | Mutable address |
|---|---|---|
| Cache lifetime | Unbounded | Bounded by acceptable staleness |
| Invalidation on republish | None needed | Purge or revalidate, at scale |
| Staleness risk | Zero | Proportional to purge completeness |
| Client complexity | Must resolve the version | None |
| Rollback | Point clients at the prior version | Purge again and hope |
| Right for | Governed products with versioned contracts | Continuously-updating operational feeds |
For a mesh whose products carry versioned contracts already, the immutable form is almost always correct, and the version lookup it requires is a small price for eliminating an entire class of correctness incident. The mutable form earns its place for genuinely continuous feeds — live sensor observations, vehicle positions — where “current” is the only meaningful version and staleness tolerance is measured in seconds.
The second boundary is between the layers of the cache itself. A tile request may be answered by the client’s own HTTP cache, a CDN edge, a shared origin cache, or the renderer. Each has different reach, different invalidation cost, and different visibility, and treating them as one undifferentiated “cache” is what produces purges that appear to work and do not.
Specification & Contract Reference
The cache key is the contract between the routing plane and every cache layer, and it must include every input that changes the response body. Omitting one produces cross-contamination that is invisible until a consumer receives another consumer’s tile.
| Key component | Why it must be in the key | Symptom when omitted |
|---|---|---|
| Product and version | Distinguishes releases | Old tiles served after a republish |
Tile coordinates z/x/y |
The tile itself | Catastrophic; never omitted in practice |
| Tile matrix set | WebMercatorQuad vs. a national grid |
Tiles from the wrong grid, silently misaligned |
| Format | .mvt vs. .png vs. .webp |
Wrong content type served from cache |
| Requested CRS | Where a layer publishes several | Reprojected tiles served for the wrong CRS |
| Style or theme id | Where server-side styling exists | One consumer’s styling served to another |
| Access scope | Where tiles differ by entitlement | A data leak — the severe case |
| Content encoding | gzip vs. br vs. identity |
Corrupt responses on mismatched clients |
The access-scope row is the one that turns a caching bug into a security incident. Where a layer serves different content to different principals — a redacted extent, a lower resolution for unentitled callers — the entitlement must be part of the key, or a cache will serve a privileged tile to an unprivileged caller that happened to request the same coordinates. The safest design avoids the problem entirely by never varying tile content by caller: entitlement decides whether a tile is served, never what it contains, so all callers who can see a tile see the same bytes.
Cache-control directives are declared per layer in the product manifest and enforced at the origin, not left to each cache’s defaults.
| Directive | Immutable layer | Mutable layer | Notes |
|---|---|---|---|
max-age |
31536000 |
60 |
Client-side lifetime |
s-maxage |
31536000 |
300 |
Shared-cache lifetime; may exceed max-age |
stale-while-revalidate |
Not needed | 600 |
Serve stale while fetching fresh, in background |
stale-if-error |
Not needed | 86400 |
Serve stale rather than fail during an origin outage |
immutable |
Present | Absent | Suppresses revalidation requests entirely |
Vary |
Accept-Encoding |
Accept-Encoding |
Keep minimal; each value multiplies the cache |
stale-while-revalidate is the single most valuable directive for a mutable tile layer, because it decouples freshness from latency: a consumer receives the cached tile immediately while the edge fetches the current one in the background, so the freshness window is bounded without any request ever waiting on the origin. stale-if-error is its counterpart for availability, and together they let a tile layer stay fast and available through both a slow origin and an unavailable one — as long as the resulting staleness is labelled in the response.
Production Implementation
The origin sets cache semantics from the layer’s declared mutability, and stamps every response with the provenance a consumer needs to know what they received.
# tile_origin.py — cache-control and provenance for a tile response.
# The layer's mutability is declared in the product manifest, never inferred.
from dataclasses import dataclass
IMMUTABLE_TTL = 31_536_000 # one year; the address can never change
MUTABLE_TTL = 60 # client-side freshness for "current" addresses
@dataclass(frozen=True)
class TileResponse:
body: bytes
version: str # e.g. v1.2.0-crs:EPSG:3857-res:10m
materialized_at: str # ISO-8601, from the publishing DAG run
from_cache: bool
def cache_headers(immutable_address: bool) -> dict:
"""Cache-Control for one tile. Immutable addresses need no revalidation at all;
mutable ones bound staleness and keep serving through an origin outage."""
if immutable_address:
return {
"Cache-Control": f"public, max-age={IMMUTABLE_TTL}, immutable",
"Vary": "Accept-Encoding",
}
return {
"Cache-Control": (
f"public, max-age={MUTABLE_TTL}, s-maxage=300, "
"stale-while-revalidate=600, stale-if-error=86400"
),
"Vary": "Accept-Encoding",
}
def provenance_headers(tile: TileResponse) -> dict:
"""Degradation must be visible in the response. A consumer that cannot tell a
live tile from a stale one cannot decide whether to act on it."""
return {
"X-Spatial-Version": tile.version,
"X-Spatial-Materialized": tile.materialized_at,
"X-Spatial-Served-From": "cache" if tile.from_cache else "origin",
}
Purging a mutable layer after a republish must be scoped to the partition that actually changed, not to the layer. Purging a whole layer to correct one region evicts a warm working set and converts a small republish into a renderer traffic spike.
#!/usr/bin/env bash
# purge_partition.sh — evict exactly the tiles a republished partition covers.
# Idempotent: purging an already-purged tile range is a no-op.
set -euo pipefail
LAYER="${1:?layer}"; MINZ="${2:?min zoom}"; MAXZ="${3:?max zoom}"
BBOX="${4:?minx,miny,maxx,maxy in EPSG:4326}"
# Enumerate the tile ranges the bbox covers at each zoom, rather than purging by prefix.
for z in $(seq "$MINZ" "$MAXZ"); do
# gdaltindex-style enumeration; emits "z x y" triples for the covered range.
python3 - "$z" "$BBOX" <<'PY' | while read -r zz xx yy; do
import math, sys
z = int(sys.argv[1]); minx, miny, maxx, maxy = map(float, sys.argv[2].split(","))
def xy(lon, lat, z):
n = 2 ** z
x = int((lon + 180.0) / 360.0 * n)
lat_r = math.radians(lat)
y = int((1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n)
return max(0, min(n - 1, x)), max(0, min(n - 1, y))
x0, y1 = xy(minx, miny, z)
x1, y0 = xy(maxx, maxy, z)
for x in range(min(x0, x1), max(x0, x1) + 1):
for y in range(min(y0, y1), max(y0, y1) + 1):
print(z, x, y)
PY
curl -sS -X PURGE "https://tiles.internal/${LAYER}/${zz}/${xx}/${yy}.mvt" >/dev/null
done
done
echo "purged ${LAYER} z${MINZ}-${MAXZ} over ${BBOX}"
Confirm the purge reached every layer of cache, which is the step most often skipped:
# The origin, the shared cache, and the edge must all report a miss immediately after a purge.
for host in origin.internal cache.internal edge.internal; do
printf '%-20s ' "$host"
curl -sS -o /dev/null -D - "https://${host}/parcels/12/2048/1361.mvt" \
| grep -iE '^(x-cache|age|x-spatial-version):' | tr '\n' ' '
echo
done
Diagnostic Runbook
- Establish which cache layer answered before anything else. Request the tile directly from the origin, then the shared cache, then the edge, comparing
X-Spatial-VersionandAgeat each. A stale tile at the edge and a current one at the origin is a purge problem; stale at all three is a publish problem. - Check the cache key before assuming a purge failed. If two requests that should differ return the same bytes, a key component is missing. Compare the full request — matrix set, format, requested CRS, encoding — between a working and a broken case.
- For a layer serving the wrong grid, look at the matrix set in the key. Tiles from
WebMercatorQuadand a national grid sharez/x/ycoordinates and are completely different tiles. Without the matrix set in the key they overwrite each other, and the symptom is geometry that renders in the wrong place at some zooms and not others. - A cache hit ratio that falls after a republish is expected; one that never recovers is not. Compare the ratio’s recovery curve against previous republishes. A ratio that plateaus low usually means the purge was over-broad and evicted the warm working set rather than the changed partition.
- If the origin is saturated, check
stale-while-revalidateis actually configured. Without it, every expiry converts into a synchronous origin fetch, so expiries synchronise and the origin sees periodic spikes rather than steady load. - When a consumer reports old data on an immutable layer, they are pinned to an old version. This is the immutable design working as intended. The fix is to check why their version resolution is not re-running, not to purge anything.
- For suspected cross-consumer contamination, verify that tile content never varies by caller. If it does, treat it as a potential data-exposure incident, not a caching bug, and confirm the access scope is in the key before restoring service.
SLA Targets & Performance Baselines
| Metric | Target | Alert threshold | Remediation |
|---|---|---|---|
| Edge cache hit ratio | > 92% |
< 80% for 30 min |
Check key cardinality and Vary |
| Origin request rate | < 8% of edge requests |
> 20% |
Expiries synchronised; add stale-while-revalidate |
| Tile p95 latency (cache hit) | < 40ms |
> 120ms |
Edge capacity or connection reuse |
| Tile p95 latency (miss) | < 300ms |
> 750ms |
Renderer path — see the vector-query SLI |
| Purge completion | < 60s estate-wide |
> 300s |
Purge fan-out; check every layer received it |
| Stale-served ratio | < 0.5% |
> 5% |
Origin unavailable; stale-if-error is masking it |
| Version-resolution cache hit | > 99% |
< 95% |
Version lookup is not itself cacheable |
The stale-served ratio deserves its own alert precisely because stale-if-error is designed to hide an origin outage from consumers. That is the correct behaviour for availability and a serious problem for observability: without a metric on it, an origin can be down for hours while every dashboard reports healthy tile serving.
Warm Sets, Cold Starts, and Where Capacity Actually Goes
Tile demand is not uniform and never has been. A handful of metropolitan extents at mid zoom levels typically account for the large majority of requests, while the long tail of rural tiles at high zoom is requested rarely or never. That distribution is the single most important fact for sizing a tile tier, and it has three consequences that surprise teams sizing from averages.
First, the warm working set is far smaller than the tile pyramid. A layer whose full pyramid is tens of terabytes may have a warm set of a few tens of gigabytes, which fits comfortably in memory across a modest cache tier. Sizing cache capacity against total pyramid size is the classic over-provisioning error; sizing it against measured working-set size — the distinct tiles requested in a rolling window — is usually an order of magnitude cheaper and performs better, because a smaller, hotter cache has a higher hit ratio per byte.
Second, cache warm-up is a real cost with a real duration, and it is paid every time capacity changes. A newly added replica starts empty, and every request routed to it is a miss that falls through to the renderer, so aggregate latency briefly worsens after a scale-up. Pre-warming a new replica against the most-requested tile ranges before it takes traffic converts that from a visible regression into a background task, and it is the difference between an autoscaler that helps and one that oscillates.
Third, a republish that evicts the warm set costs more than the republish itself. Purging an entire layer to correct one region throws away exactly the tiles that were most expensive to compute and most likely to be requested next, and the renderer absorbs the resulting miss storm. This is the strongest practical argument for partition-scoped purging, and for immutable addressing where the layer’s contract allows it — under immutable addressing a republish evicts nothing at all, because the old version’s tiles remain valid at their own addresses until nothing references them.
| Sizing input | Wrong basis | Right basis |
|---|---|---|
| Cache capacity | Total pyramid size | Distinct tiles requested in 24h |
| Renderer capacity | Peak request rate | Peak miss rate |
| Scale-up stabilization | A round number | Measured warm-up duration |
| Purge scope | The layer | The republished partition |
| Zoom range to precompute | All zooms | Zooms above the observed request floor |
The last row is worth acting on. Precomputing a full pyramid to zoom 18 across a national extent generates enormous numbers of tiles that will never be requested. Measuring the zoom distribution of actual traffic and precomputing only down to where demand becomes sparse — rendering the rest on demand and caching it — routinely cuts both pipeline cost and storage substantially with no consumer-visible difference.
Governance & Compliance Notes
Cached spatial data is still governed data, and two obligations follow. Where a jurisdiction restricts where data may be stored, an edge cache is storage — a tile cached in a region the product’s residency flag excludes is a residency breach regardless of where the origin sits. Cache topology therefore has to be derived from the product’s declared residency rather than from a global CDN default, which in practice means residency-constrained layers are served from a restricted set of edges and the routing plane refuses to answer them from anywhere else.
Retention is the second. A cache holding tiles for a year holds a copy of the data for a year, including data that has since been corrected or withdrawn. For a withdrawal to be real — a parcel removed for legal reasons, an imagery scene restricted after publication — the purge must reach every cache layer and be verifiable, which is only tractable if purge is a first-class, audited operation rather than an operational convenience. Immutable addressing helps here too: withdrawing a version means invalidating one version prefix, and clients that re-resolve receive the successor.
Related
- Cross-Domain Routing Strategies — the routing decisions a cache sits in front of
- Rate Limiting Spatial API Traffic — cost-weighted limits on the same tile path
- API Gateway Mapping for GIS Services — where cache directives are applied at the edge
- HPA Configuration for Spatial Tile Cache Nodes — scaling the cache tier on hit ratio
- Federated Ownership & Routing Architecture — up to the section overview