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
FAQ
Is a one-year max-age safe?
On a versioned path, yes — the bytes at that URL can never change, so there is nothing for a stale copy to be wrong about. On an unversioned path it is the most expensive mistake in this section.
What does immutable add over max-age?
It suppresses revalidation on reload. Without it, a reader refreshing the page issues a conditional request per tile and receives forty 304s — cheap responses, and still forty round trips.
Does the browser honour it?
All current browsers do. Older ones ignore the directive and fall back to max-age, which is a graceful degradation to the behaviour you would have had anyway.
What about the style document?
Never immutable. It is the one resource that must change in place, and pinning it would freeze every reader on one version prefix for a year.
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.