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.


Three cache tiers and their TTLs A tile request flows left to right through three boxes: Browser cache holding an immutable tile for one year, CDN edge holding it via s-maxage, and Origin storing the versioned object. A miss at each tier falls through to the next. Browser cache max-age=1yr immutable miss CDN edge s-maxage shared, per-POP miss Origin object store / tile server /v{hash}/z/x/y.pbf content-addressed, never overwritten warm on >95% of requests for a stable basemap private, per-client answered only on cold cache

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 60300 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 3060
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:

  1. immutable belongs 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 full max-age.
  2. style.json is the fast-moving control plane. A designer edits a paint property and expects it live in minutes, so it takes a 5-minute max-age with a short stale-while-revalidate grace window.
  3. The unversioned alias is the revalidation path. If you must expose a stable /latest/ URL, keep its body cacheable but force conditional revalidation with ETag/Last-Modified so a 304 costs 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.

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

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

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

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

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

bash
# 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.url must 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 .pmtiles object 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.

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

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

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

Next reading Cache-Control Headers for Immutable Vector Tiles Next reading Versioned Tile URL Rotation Without Cache Purges