Choosing Cache Keys for Vector Tile Endpoints
A cache key that omits an input which changes the response body causes cross-contamination; one that includes an input which does not causes a cache that never hits. Both failures are common, both are invisible in ordinary monitoring, and one of them is a data-exposure incident. This guide derives the key from first principles for a vector tile endpoint, tests it for both failure directions, and shows how to change it without discarding a warm cache. It implements the keying specification in Edge Caching and Tile Delivery Topology within Federated Ownership & Routing Architecture, and it depends on the header discipline established in Header-Based Routing for Spatial Domains.
Prerequisites
| Requirement | Value / Assumption | Notes |
|---|---|---|
| Tools | curl, jq, an edge config CLI, hey or equivalent for load |
Key testing is request-level |
| Routing | The routing header is set at the edge, not by the client | A client-controlled key input is a bypass |
| CRS convention | Tiles served in EPSG:3857 via WebMercatorQuad unless declared otherwise |
Multiple CRS means CRS is in the key |
| Access model | Tile content does not vary by caller | If it does, entitlement enters the key |
| Environment | TILE_HOST, PRODUCT, VERSION |
Exported before running |
The access-model row is the one to settle before writing any configuration. If tile content varies by caller, the key must carry the entitlement and cache efficiency collapses; if entitlement decides only whether a tile is served, the key stays small and every authorised caller shares one cached object. Design for the second wherever the data model allows it.
Step-by-Step Implementation
1. Enumerate every input that changes the response body
The key is derived, not chosen. Start by listing what the origin actually reads when producing the response.
# Instrument the origin to log every request attribute it consumed for one tile.
# Anything that appears here and is absent from the key is a contamination risk.
curl -sS -D - -o /dev/null \
-H "x-spatial-domain: cadastral" \
-H "x-spatial-crs: EPSG:3857" \
-H "accept-encoding: br" \
"https://${TILE_HOST}/${PRODUCT}/${VERSION}/12/2048/1361.mvt" \
| grep -i '^x-origin-consumed:'
# e.g. x-origin-consumed: path,version,matrixset,format,encoding
Verify the list against the response by mutating one input at a time and confirming the body changes:
for enc in gzip br identity; do
printf '%-9s ' "$enc"
curl -sS -H "accept-encoding: $enc" \
"https://${TILE_HOST}/${PRODUCT}/${VERSION}/12/2048/1361.mvt" \
| sha256sum | cut -c1-16
done
# Three distinct digests means encoding changes the body and belongs in the key.
2. Compose the key, narrowest first
Order matters for readability and for prefix-based purging: putting the product and version first makes a version prefix purgeable in one operation.
# Edge cache key composition, most-significant first.
cache_key =
product # cadastral/parcels
+ "/" + version # v1.2.0-crs:EPSG:3857-res:10m — immutable address
+ "/" + matrixset # WebMercatorQuad
+ "/" + z + "/" + x + "/" + y
+ "." + format # mvt | pbf | json
+ "|enc=" + encoding
Notably absent: the caller identity, the request time, any query parameter the origin does not read, and the Referer or User-Agent headers that a naive Vary configuration drags in. Each of those would multiply the cache without changing the body.
# Edge configuration — an explicit key rather than an implicit one derived from Vary.
proxy_cache_key "$spatial_product/$spatial_version/$matrix_set/$uri|enc=$http_accept_encoding";
# Vary is kept minimal: every value in it multiplies the stored object count.
proxy_hide_header Vary;
add_header Vary "Accept-Encoding" always;
Verify the key is neither too narrow nor too wide by checking both directions in one pass:
# Too narrow: two requests that SHOULD differ must not return identical bytes.
a=$(curl -sS -H "x-spatial-crs: EPSG:3857" "https://${TILE_HOST}/${PRODUCT}/${VERSION}/12/2048/1361.mvt" | sha256sum)
b=$(curl -sS -H "x-spatial-crs: EPSG:4326" "https://${TILE_HOST}/${PRODUCT}/${VERSION}/12/2048/1361.mvt" | sha256sum)
[ "$a" != "$b" ] && echo "CRS is distinguished" || echo "CONTAMINATION: CRS missing from key"
# Too wide: two requests that should be identical must hit the same object.
curl -sS -o /dev/null -D - -H "user-agent: A" "https://${TILE_HOST}/${PRODUCT}/${VERSION}/12/2048/1361.mvt" | grep -i x-cache
curl -sS -o /dev/null -D - -H "user-agent: B" "https://${TILE_HOST}/${PRODUCT}/${VERSION}/12/2048/1361.mvt" | grep -i x-cache
# The second must report HIT. A MISS means User-Agent is fragmenting the cache.
3. Measure key cardinality against the working set
A correct key can still be a bad key if it produces more distinct objects than the cache can hold.
# Distinct keys observed in 24h, against distinct tiles actually requested.
# A ratio meaningfully above 1.0 means the key is fragmenting on something inert.
curl -sS "$PROM/api/v1/query" --data-urlencode \
'query=count(count by (cache_key) (tile_requests_total)) / count(count by (z,x,y) (tile_requests_total))' \
| jq -r '.data.result[0].value[1]'
# 1.0–1.2 is healthy (encoding variants). Above 2.0, something inert is in the key.
4. Change the key without discarding the warm set
A key change invalidates every stored object at once, which converts a configuration edit into a renderer traffic spike.
# Roll the key with a version salt, shifting traffic gradually rather than at once.
# Stage 1: 5% of traffic uses the new key composition.
edgectl config set cache.key.version 2 --traffic-share 5
sleep 900 && edgectl stats --key-version 2 | jq '{hit_ratio, origin_rps}'
# Stage 2 and 3 only after the new key's hit ratio has warmed.
edgectl config set cache.key.version 2 --traffic-share 50
edgectl config set cache.key.version 2 --traffic-share 100
Verify the origin absorbed the shift without breaching its own SLO:
curl -sS "$PROM/api/v1/query" --data-urlencode \
'query=sum(rate(tile_origin_requests_total[5m])) / sum(rate(tile_edge_requests_total[5m]))' \
| jq -r '.data.result[0].value[1]' # should stay below 0.20 throughout the roll
Configuration Reference
| Key component | Include when | Cardinality cost | Omission symptom |
|---|---|---|---|
| Product | Always | 1× per product | Cross-product contamination |
| Version | Immutable addressing | 1× per live version | Stale tiles after republish |
| Matrix set | More than one is published | 1× per set | Misaligned tiles at some zooms |
z/x/y |
Always | The tile count itself | Catastrophic |
| Format | More than one is served | 1× per format | Wrong content type from cache |
| Encoding | Always | 2–3× | Corrupt responses on some clients |
| Requested CRS | More than one is published | 1× per CRS | Wrong projection served |
| Entitlement | Content varies by caller | 1× per entitlement class | Data exposure |
User-Agent |
Never | Unbounded | Cache never hits |
| Query string | Only params the origin reads | Unbounded if unfiltered | Cache never hits |
Common Failure Modes & Fixes
Hit ratio collapses after an unrelated configuration change.
Root cause: a Vary header added upstream — often Accept-Language or Origin from a CORS change — silently entering the key. Fix: hide the upstream Vary and set it explicitly at the edge, as above.
One consumer occasionally receives another’s tile. Root cause: tile content varies by caller and entitlement is not in the key. Fix: treat as a potential exposure incident; either add entitlement to the key or, preferably, stop varying content by caller.
Tiles render in the wrong place at some zoom levels only.
Root cause: two matrix sets sharing z/x/y coordinates with the matrix set absent from the key. The overlap is partial because the grids agree at some levels. Fix: add the matrix set; purge affected prefixes.
Cache never hits despite a stable key. Root cause: an unfiltered query string, so a client appending a cache-buster or a session parameter makes every request unique. Fix: filter the query string to the parameters the origin actually reads and drop the rest before keying.
Hit ratio is high and the origin is still saturated. Root cause: the misses are concentrated on a few extremely expensive tiles — usually low zoom levels covering large extents. Fix: precompute those specifically rather than raising cache capacity; the ratio is fine and the cost distribution is not.
FAQ
Should the product version be in the key or in the path?
In the path, which puts it in the key automatically and buys immutability at the same time. A version in the path makes each release a distinct, permanently-valid address, so a republish invalidates nothing and needs no purge — the old version’s tiles simply stop being requested as clients re-resolve. Keeping the version only in a header and out of the path means every republish requires a purge across every cache layer, and any gap in that purge is silent staleness. The cost is that clients resolve the current version before requesting tiles, which is one small, highly cacheable metadata request.
How much does encoding really cost in cardinality?
Two to three times the object count, which sounds worse than it is. In practice most clients negotiate br or gzip and the identity variant is rare, so the effective multiplier is closer to two and the extra objects are the same tiles in different compressions. The alternative — normalising to one encoding at the edge and recompressing per response — trades storage for CPU on every request, which is the wrong trade for a tile workload. Keep both variants and keep Vary restricted to Accept-Encoding so nothing else joins them.
Is it ever right to put the caller in the key?
Only when tile content genuinely varies by caller, and that design should be avoided rather than accommodated. Varying content by entitlement means the cache holds one copy per entitlement class per tile, which multiplies storage and destroys the sharing that makes tile caching worthwhile. The better model is that entitlement decides whether a caller may fetch a tile, never what the tile contains — a redacted region becomes a separate product with its own extent, cached independently, rather than a filtered view of a shared one.
What is a healthy key-cardinality ratio?
Distinct cache keys over distinct tiles requested should sit between 1.0 and roughly 1.2 for a single-CRS, single-format layer serving two encodings. A ratio near 2.0 usually means a second format or CRS is genuinely in play; above that, something inert is fragmenting the cache and it is worth enumerating the key components against the origin’s consumed-input log. The ratio is cheap to compute and moves immediately when a configuration change goes wrong, which makes it a better early signal than hit ratio alone.
Related
- Edge Caching and Tile Delivery Topology — the parent topic and the full cache specification
- Configuring Stale-While-Revalidate for Tile CDNs — bounding staleness once the key is right
- Purging Tile Caches After a Partition Republish — scoped invalidation for mutable addresses