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.
# 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 conditionalGETon every hard reload; each is a full RTT to the edge even when the answer is304. On a map that loads dozens of tiles per viewport,immutableremoves 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.
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.
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
Related
- Versioned Tile URL Rotation Without Cache Purges — the prefix-rotation scheme that makes a one-year immutable lifetime safe by publishing each rebuild to a fresh path.
- Configuring R2 and S3 for PMTiles Range Requests — the bucket-level
Accept-Ranges, CORS, and content-type settings that let these immutable headers apply to byte ranges of a single archive.