CDN Cache Headers & Versioned Tile URLs
A vector-tile CDN has exactly two failure directions and no safe middle. Cache a tile too aggressively on a mutable URL and a rebuild pins stale geometry in edge caches for a year; cache it too weakly and every map pan re-fetches from origin, inflating egress and latency. This guide sets the correct Cache-Control directives per path — immutable one-year caching for versioned tiles, a short TTL for style JSON, and ETag-driven 304 revalidation for the unversioned alias — then wires those headers to a rotating version prefix so every deploy publishes to a fresh path and never triggers a global CDN purge.
Prerequisites
| Requirement | Why it is needed |
|---|---|
| A CDN in front of the origin | Cache-Control only helps if a shared edge cache (Cloudflare, Fastly, CloudFront) sits between the client and the tile store |
| Versioned build output | The tile path must carry a schema hash or build ID so the URL is content-addressed and safe to cache forever |
| Ability to set response headers | You need write access to a _headers file, an nginx/Caddy config, or per-object metadata on the bucket |
| A container built by the pipeline | An MBTiles file served dynamically, or a .pmtiles archive on object storage delivered by range requests |
| A style document | The MapLibre GL style whose sources.url points at the tile path and must be cached on a short TTL |
The whole strategy rests on one invariant: a tile URL is either immutable or mutable, never both. A versioned path like /va1b2c3d4/10/301/384.pbf names one specific build and its bytes will never change, so it can be cached for a year. An alias like /latest/style.json deliberately changes, so it must revalidate. Mixing the two — caching a mutable path immutably — is the single most common cause of a production tile incident.
Core Concept: The Cache-Control Directive Inventory
Cache-Control is a comma-separated list of directives on the response. The CDN and the browser each parse the same header but honor different subsets. The table below is the full inventory that matters for tiles, and which value belongs on each of the three asset classes you serve.
| Directive | Meaning | Tiles (/v{hash}/…pbf) |
style.json |
Unversioned alias |
|---|---|---|---|---|
public |
Any shared cache may store it | yes | yes | yes |
max-age=<s> |
Browser freshness lifetime in seconds | 31536000 (1 yr) |
300 (5 min) |
0 or small |
s-maxage=<s> |
Overrides max-age for shared (CDN) caches only |
inherit / 31536000 |
60–300 |
short + revalidate |
immutable |
Body will never change; skip revalidation even on reload | yes | no | no |
stale-while-revalidate=<s> |
Serve stale up to N s while fetching fresh in background | not needed | 60 |
30–60 |
no-cache |
Store, but revalidate every time before use | never | avoid | acceptable |
must-revalidate |
Once stale, never serve without a successful revalidation | never | optional | yes |
Three rules fall out of this table:
immutablebelongs only on content-addressed paths. It tells Safari and Chrome to skip the revalidation they otherwise perform on a hard reload. On a mutable URL it is a footgun — the browser will refuse to notice a change for the fullmax-age.style.jsonis the fast-moving control plane. A designer edits a paint property and expects it live in minutes, so it takes a 5-minutemax-agewith a shortstale-while-revalidategrace window.- The unversioned alias is the revalidation path. If you must expose a stable
/latest/URL, keep its body cacheable but force conditional revalidation withETag/Last-Modifiedso a304costs a header round-trip, not a full tile download.
Step-by-Step Implementation
Step 1 — Mark versioned tile paths immutable
Every tile under a version prefix gets the maximal directive set. One year is the practical ceiling max-age honors, and immutable removes the revalidation round-trip on reload.
# Cloudflare R2 / S3 object upload with a one-year immutable header
aws s3 cp roads.pmtiles \
s3://tiles.example.com/va1b2c3d4/roads.pmtiles \
--content-type application/octet-stream \
--cache-control "public, max-age=31536000, immutable"
For a dynamic tile server the same header is applied at the edge, not the origin — you set it as a CDN response header rule scoped to the /v*/ path pattern so the origin stays stateless. The exact directive and its interaction with ETag and gzip is documented in Cache-Control headers for immutable vector tiles.
Verify: the response reports the full directive and, on a second request, a cache HIT.
curl -sI "https://tiles.example.com/va1b2c3d4/10/301/384.pbf" \
| grep -iE "cache-control|cf-cache-status|age"
# cache-control: public, max-age=31536000, immutable
# cf-cache-status: HIT
# age: 8123
Step 2 — Keep style.json on a short TTL
The style document is not content-addressed, so it must stay fresh. A 5-minute max-age bounds how long a client renders an old style; stale-while-revalidate=60 lets the edge serve the slightly-stale copy instantly while it re-fetches in the background, hiding origin latency from the user.
aws s3 cp style.json \
s3://tiles.example.com/style.json \
--content-type application/json \
--cache-control "public, max-age=300, stale-while-revalidate=60"
Because the style’s sources.url points at a versioned tile prefix, updating the style is how you atomically flip clients onto a new build. That coupling — style TTL short, tile TTL infinite — is the whole reason the versioning scheme works.
Step 3 — Set ETag / Last-Modified for 304 revalidation on the alias
If you expose an unversioned convenience URL (a /latest/ alias, or a style.json past its max-age), a validator header lets the client ask “has this changed?” cheaply. The origin answers 304 Not Modified with no body when the ETag still matches.
# Object stores set a strong ETag (the MD5 or a multipart digest) automatically.
# For a custom origin, emit a content hash as the ETag:
curl -sI "https://tiles.example.com/latest/style.json" \
| grep -iE "etag|last-modified|cache-control"
# etag: "9f8c1e2a44b0"
# last-modified: Sun, 12 Jul 2026 09:00:00 GMT
# cache-control: public, max-age=300, must-revalidate
# A conditional request returns 304 with no body when unchanged:
curl -sI "https://tiles.example.com/latest/style.json" \
-H 'If-None-Match: "9f8c1e2a44b0"'
# HTTP/2 304
Verify: the conditional request returns 304 and zero body bytes, not 200.
Step 4 — Verify the whole chain with curl -I
Before a deploy is considered live, confirm each asset class carries the header it should. Script it so the check runs in CI.
#!/usr/bin/env bash
set -euo pipefail
BASE="https://tiles.example.com"
PREFIX="va1b2c3d4"
echo "== versioned tile (want: immutable, 1yr) =="
curl -sI "$BASE/$PREFIX/10/301/384.pbf" | grep -i cache-control
echo "== style.json (want: max-age=300) =="
curl -sI "$BASE/style.json" | grep -i cache-control
echo "== content negotiation (want: protobuf + gzip) =="
curl -sI "$BASE/$PREFIX/10/301/384.pbf" \
| grep -iE "content-type|content-encoding"
A green run shows the immutable directive on the tile, the 5-minute TTL on the style, and Content-Type: application/x-protobuf with Content-Encoding: gzip on the tile body.
Optimization Knobs
| Knob | Conservative | Aggressive | Trade-off |
|---|---|---|---|
Browser max-age vs edge s-maxage |
equal (31536000) |
short max-age, long s-maxage |
Splitting them lets you shorten browser retention while keeping a warm edge; costs one conditional request per client after max-age |
stale-while-revalidate window on style |
30 s |
120 s |
Longer window hides origin latency better but widens the interval during which clients render a stale style |
| Purge-by-prefix vs purge-all | purge one prefix | purge everything | Prefix purge is surgical and fast; a global purge cold-starts every POP and can spike origin traffic 10× |
| Edge TTL floor override | honor origin header | force minimum edge TTL | Forcing a floor protects a slow origin, but a mistaken floor on a mutable path re-introduces the staleness bug |
| Tiered / shield cache | off | on | A shield POP collapses origin requests after a purge, cutting stampede load; adds one hop of latency on a cold miss |
The cleanest deployment avoids purges entirely: rotate the version prefix, let immutable do its job, and reserve any purge capability for emergencies. That approach is detailed in versioned tile URL rotation without cache purges.
Integration with Adjacent Stages
The version prefix is not arbitrary — it is derived from the build. The tile generation step emits a container whose metadata (layer names, fields, min/max zoom, the MVT tile-JSON) hashes to a stable identifier. That hash becomes the path prefix, so identical rebuilds land on the same URL and genuinely-changed builds land on a new one.
# Derive the prefix from the PMTiles header + tile-JSON, not from the wall clock
SCHEMA_HASH=$(pmtiles show roads.pmtiles --header-json \
| python3 -c "import sys,hashlib;print('v'+hashlib.sha1(sys.stdin.buffer.read()).hexdigest()[:8])")
echo "$SCHEMA_HASH" # e.g. va1b2c3d4
Two couplings must hold for the scheme to stay correct:
- The style’s
sources.urlmust match the current prefix. The MapLibre style is the pointer that moves clients from the old build to the new one; if it lags, clients keep hitting the old (still-cached, still-valid) prefix, which is safe but stale. - The container choice propagates into caching. A static
.pmtilesobject cached immutably behaves differently from a dynamic MBTiles server whose responses the CDN caches per/{z}/{x}/{y}path. The trade-off is argued in PMTiles vs MBTiles for CDN delivery; the delivery mechanics of the static path live in PMTiles range-request delivery.
Troubleshooting
1. Stale tiles pinned for a year
Symptom: a fixed geometry change is invisible in production days after deploy; hard reload does not help.
Diagnosis: the tile URL is mutable but was served immutable.
curl -sI "https://tiles.example.com/roads/10/301/384.pbf" | grep -i cache-control
# cache-control: public, max-age=31536000, immutable <-- on an unversioned path
Fix: never send immutable on a path that can be overwritten. Move the build under a version prefix and put the immutable header there; keep the unversioned alias on max-age=300, must-revalidate. There is no client-side recovery short of a query-string change or a full purge, which is exactly what versioning avoids.
2. Tiles never cached because of a query string
Symptom: edge hit ratio sits near 0%; every tile is a MISS.
Diagnosis: the tile URLs carry a cache-buster query string (?v=1720771200 or ?t=<timestamp>), and the CDN’s default cache key includes the query string, so no two requests share a key.
curl -sI "https://tiles.example.com/va1b2c3d4/10/301/384.pbf?v=1720771200" \
| grep -i cf-cache-status
# cf-cache-status: MISS (every time)
Fix: put the version in the path, not the query string, and either strip the query string from the cache key or drop it entirely. Path-based versioning is cacheable and immutable; query-string versioning defeats both.
3. Cache stampede after a deploy
Symptom: origin request rate spikes 10× for 30–90 seconds immediately after a style flip.
Diagnosis: the new prefix has no warm edge entries, so every client misses the same cold objects simultaneously.
# Watch the miss burst right after the flip
curl -sI "https://tiles.example.com/vNEWHASH/10/301/384.pbf" | grep -i cf-cache-status
# cf-cache-status: MISS
Fix: pre-warm the new prefix with synthetic requests to the busiest tiles before flipping the style pointer, or run a short dual-deploy window serving both prefixes. A shield/tiered cache also collapses concurrent misses into one origin fetch.
4. Mixed old and new schema in one map
Symptom: some layers render, others vanish or throw a “source layer not found” error after a rebuild that renamed a layer.
Diagnosis: the browser holds year-cached tiles from the old prefix while the freshly-fetched style expects the new schema — the layer names no longer line up.
Fix: this is the failure the version prefix exists to prevent. Because each build lives under its own immutable prefix and the style points at exactly one prefix, old tiles and a new style can never be requested together — provided you never overwrite a prefix in place. If you see this, a prefix was reused; publish to a fresh prefix and update sources.url atomically.
Further Reading
Cache-Control Headers for Immutable Vector Tiles — the exact public, max-age=31536000, immutable header for versioned tile paths, how it interacts with ETag, gzip, and edge TTLs, and the mistakes that quietly re-enable revalidation.
Versioned Tile URL Rotation Without Cache Purges — how to compute a stable schema hash, publish each rebuild to a fresh /v{hash}/ path, flip the style pointer, and age old prefixes out with a lifecycle rule instead of purging.
Parent: Tile Serving & CDN Delivery
Related
- PMTiles Range-Request Delivery from Object Storage — hosting a single
.pmtilesarchive on R2 or S3, theAccept-Rangesand CORS headers it needs, and how immutable caching applies to byte ranges. - Tile Server Selection: Martin, tileserver-gl & pg_tileserv — choosing the origin whose responses these cache headers are applied to, with throughput and operational trade-offs.
- PMTiles vs MBTiles for CDN Delivery — the container decision that determines whether you cache whole
/{z}/{x}/{y}responses or byte ranges of one object.