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.

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

A key can be wrong in two directions, and only one of them is visibleTwo failure directions compared on four properties. A key that is too narrow omits an input that changes the response, so one consumer receives another content — a data exposure in the entitlement case — and monitoring shows a healthy hit ratio throughout. A key that is too wide includes an inert input such as the user agent, so the cache never hits, the origin saturates, and the hit ratio makes the problem obvious. The asymmetry is the reason both directions are tested explicitly rather than relying on the ratio alone.Too narrowToo wideSymptomwrong content servedcache never hitsHit ratiolooks healthyobviously lowWorst casedata exposureorigin saturationFound byexplicit difference testthe hit ratio

Verify the list against the response by mutating one input at a time and confirming the body changes:

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

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

The key composed most-significant first, so a version prefix is purgeable in one callThe cache key is composed in order: product, then version, then matrix set, then the tile coordinates, then format, then content encoding. Ordering it this way means a whole version can be purged by prefix in a single operation, and a whole product in one more. Three components drop onto a rail showing what their omission produces: stale tiles after a republish, misaligned geometry from the wrong grid, and corrupt responses on clients that negotiated a different encoding.productcadastral/parcelsversionimmutable addressmatrix setWebMercatorQuadz/x/y · formatthe tile itselfencodingbr | gzip | identity///|omit → stale after republishomit → wrong grid servedomit → corrupt responsesCross-contaminationinvisible in monitoring

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.

nginx
# 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:

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

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

Rolling a key change without discarding the warm setFive stages of a key rollout. The new key composition is deployed to five percent of traffic and left to warm for fifteen minutes. Its hit ratio and the origin request rate are checked before proceeding. Traffic moves to fifty percent and is checked again, then to one hundred percent. The old key composition is retired only once the new one warmed. Switching all traffic at once instead invalidates every stored object simultaneously, which converts a configuration edit into a renderer traffic spike.5% · new keywarms in parallelcheckhit ratio, origin rps50% · shiftedre-checked100% · completeold key idleretire old keynothing references itSwitching at once invalidates every object and sends the miss storm to the renderer

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

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