Cache-Control Headers for Immutable Vector Tiles

Send Cache-Control: public, max-age=31536000, immutable on versioned tile paths only — a URL whose bytes are content-addressed and will never change. That one header gives you a near-100% edge hit ratio and zero revalidation round-trips, and immutable is the part that stops Safari and Chrome from re-checking the tile on a hard reload.

When to Use This

Use this header when, and only when, the tile URL is content-addressed. In practice that means the path carries a schema hash or build ID: /va1b2c3d4/{z}/{x}/{y}.pbf, or a single /va1b2c3d4/roads.pmtiles archive. Under that scheme the bytes at a given URL are fixed for all time — a new build publishes to a new prefix, never overwrites the old one.

Do not put this header on a mutable path. An unversioned alias like /latest/, /roads/{z}/{x}/{y}.pbf, or a bare style.json can change under the same URL, and immutable tells the browser to ignore that change for the full max-age — up to a year. The parent guide covers the short-TTL headers those mutable paths take instead. The rule is binary: content-addressed gets immutable; anything overwritable does not.

Specification Detail

Each directive in the header does one specific job:

Directive Meaning Value here
public Any shared cache (CDN, proxy) may store the response, not just the private browser cache present
max-age=<seconds> Freshness lifetime; the browser serves from cache without contacting the network for this long 31536000 (365 days, the practical ceiling)
immutable The body will not change for the life of max-age; skip conditional revalidation even on an explicit reload present

max-age=31536000 is one year in seconds. Longer values are permitted by the spec but clamped by browsers, so a year is the effective maximum. immutable is defined in RFC 8246 and is honored by Firefox and Safari to suppress the revalidation those browsers otherwise issue on reload; Chrome achieves the same effect through its own reload heuristics. On a content-addressed URL the directive is always safe because there is nothing to revalidate against.

For the MVT payload itself the response must also carry Content-Type: application/x-protobuf (or application/vnd.mapbox-vector-tile) and, because Tippecanoe gzips tiles inside the container by default, Content-Encoding: gzip.

Production Command

Three equivalent ways to attach the header, depending on where your tiles live.

bash
# 1) Cloudflare Pages / R2 static hosting — a _headers file rule
cat > _headers <<'EOF'
/v*/*
  Cache-Control: public, max-age=31536000, immutable
  Content-Type: application/x-protobuf
  Content-Encoding: gzip
  Access-Control-Allow-Origin: *
EOF

# 2) nginx origin fronted by a CDN
#   location block matches any versioned prefix:
#   location ~ ^/v[0-9a-f]+/ {
#       add_header Cache-Control "public, max-age=31536000, immutable" always;
#       add_header Access-Control-Allow-Origin "*" always;
#       gzip off;   # tiles are already gzipped in the container
#   }

# 3) Uploading a PMTiles archive to S3/R2 with the header baked into object metadata
aws s3 cp roads.pmtiles \
  s3://tiles.example.com/va1b2c3d4/roads.pmtiles \
  --content-type application/octet-stream \
  --cache-control "public, max-age=31536000, immutable"

# Verify the served header and that the edge is caching it:
curl -sI "https://tiles.example.com/va1b2c3d4/10/301/384.pbf" \
  | grep -iE "cache-control|content-encoding|cf-cache-status|age"
# cache-control: public, max-age=31536000, immutable
# content-encoding: gzip
# cf-cache-status: HIT
# age: 42317

The nginx gzip off line is deliberate: the tiles are stored pre-compressed, so letting nginx or the CDN compress again yields a double-gzip body MapLibre cannot parse.

Interaction Effects

With ETag. An ETag is a revalidation token, and immutable explicitly tells the client never to revalidate. Sending both is harmless but redundant on a versioned tile — the ETag is simply never consulted while the response is fresh. Object stores emit an ETag automatically, so you generally leave it in place; it becomes useful only if the same path is ever downgraded to a mutable alias, which it should not be. This is the mirror image of the mutable-alias case in the parent guide, where the ETag does all the work and immutable is forbidden.

With gzip / Content-Encoding. Caching and compression are orthogonal but both ride on this response. The cache stores whatever bytes the origin sent, so if the tile is pre-gzipped the edge caches the gzipped bytes and serves them with Content-Encoding: gzip intact. The failure mode is a CDN that re-compresses an already-gzipped body; disable automatic compression on .pbf and .pmtiles paths. When a .pmtiles archive is served by range requests, compression is handled inside the archive, so the object is transferred with Content-Encoding: identity and range offsets stay byte-accurate.

With edge s-maxage. If you want the browser to hold a tile for less than a year while the edge holds it longer, split the values: max-age=86400, s-maxage=31536000, immutable. The CDN honors s-maxage; the browser honors max-age. For content-addressed tiles this is rarely worth the complexity — equal values are simpler and just as correct. The full trade-off is in versioned tile URL rotation, which is what makes the year-long lifetime safe in the first place.

Performance Impact

  • Edge hit ratio approaches 100% for a stable basemap. Once a tile is warm at a POP, every subsequent request in the region is answered from the edge until the object ages out — which, at a one-year TTL, effectively never happens before the next rebuild rotates the prefix.
  • Zero revalidation round-trips. Without immutable, Safari and Firefox fire a conditional GET on every hard reload; each is a full RTT to the edge even when the answer is 304. On a map that loads dozens of tiles per viewport, immutable removes that entire wave — the difference is visible as faster repeat loads and lower request counts in the network panel.
  • Lower origin egress. Because misses only occur when a genuinely new prefix appears, origin fetches are bounded by rebuild frequency, not by traffic. A busy basemap can run at well under 5% origin egress.

Common Mistakes

1. immutable on /latest/. Putting the header on a mutable alias pins stale tiles for a year with no client-side recovery.

bash
curl -sI "https://tiles.example.com/latest/10/301/384.pbf" | grep -i cache-control
# cache-control: public, max-age=31536000, immutable   <-- WRONG on a mutable path

Fix: serve mutable aliases with public, max-age=300, must-revalidate and reserve immutable for /v{hash}/ paths.

2. Missing immutable, so Safari still revalidates. A header of just public, max-age=31536000 caches correctly but does not suppress reload-time revalidation in Safari and Firefox. The symptom is a burst of 304 responses on every hard reload despite a full cache. Fix: add the immutable token.

3. A query-string cache-buster defeating immutability. Appending ?v=123 to a tile URL makes the path effectively mutable again — and with most CDN cache keys including the query string, every unique value is a fresh MISS.

bash
curl -sI "https://tiles.example.com/va1b2c3d4/10/301/384.pbf?v=123" | grep -i cf-cache-status
# cf-cache-status: MISS   (on every distinct query value)

Fix: carry the version in the path, never the query string, and drop the query string from the cache key.


Parent: CDN Cache Headers & Versioned Tile URLs