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

Immutable versus mutable tile addresses, and what each decidesSix properties compared between a tile address carrying the product version and one meaning current. An immutable address permits unbounded cache lifetime, needs no invalidation on republish, and carries zero staleness risk, at the cost of a version lookup the client must perform. A mutable address needs no client lookup and requires a purge across every cache layer on every republish, with staleness proportional to how completely that purge lands. Rollback under immutable addressing is repointing clients at the prior version; under mutable addressing it is another purge.ImmutableMutableCache lifetimeunboundedbounded by stalenessInvalidationnone neededpurge, at scaleStaleness riskzeropurge completenessClient workresolve the versionnoneRollbackrepoint clientspurge againRight forversioned productscontinuous 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.

python
# 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",
    }

The four cache layers a tile request may be answered from, and what a purge must reachFour layers from the client outward. The browser cache is bounded by max-age and cannot be purged at all. The edge point of presence is bounded by s-maxage and is purged per region. The shared origin cache is purged centrally. The renderer is the only layer that computes rather than stores. A purge that reaches the edge and misses the shared cache leaves the edge to re-fetch the stale object it just discarded, which is why the purge runs origin-outward and every layer is enumerated rather than assumed.Browser cachebounded by max-agecannot be purgedEdge PoPbounded by s-maxagepurge per regionShared origin cachecentralpurge firstRenderercomputes, does not storethe cost being avoided

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.

bash
#!/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:

bash
# 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

  1. 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-Version and Age at 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.
  2. 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.
  3. For a layer serving the wrong grid, look at the matrix set in the key. Tiles from WebMercatorQuad and a national grid share z/x/y coordinates 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.
  4. 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.
  5. If the origin is saturated, check stale-while-revalidate is 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.
  6. 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.
  7. 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.

Warm working set against full pyramid size, for one national vector layerFour measures of the same layer in gigabytes, with the cache tier capacity drawn as a dashed line at 64 gigabytes. The full tile pyramid to zoom 18 is about 14,000 gigabytes. The tiles requested at least once in thirty days are around 210. The distinct tiles requested in twenty-four hours — the genuine warm working set — are about 47, which fits comfortably in a modest cache tier. Sizing cache capacity against pyramid size rather than against measured working set is the classic over-provisioning error.full pyramid to z18300 GB≈14,000 GB, off scalerequested in 30 days210 GBwarm set, 24h47 GBfits the tiertop 100 tiles2 GBmost of the trafficcache tier

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.