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

What each part of the tile Cache-Control header doesThe Cache-Control header for a versioned tile split into its public, max-age and immutable directives, with what each one changes.HEADER ANATOMYpublicwho may cacheshared caches, not justthe browsermax-age=31536000how longone year, the practicalmaximumimmutablerevalidationno conditional request,even on reload
immutable is the one people leave off. Without it a browser still revalidates on reload, which is exactly the request the versioned path exists to prevent.

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

What immutable removes on a reloadConditional request counts on a page reload under four header configurations: no Cache-Control, max-age only, max-age with ETag, and max-age with immutable.REQUESTS SAVEDconditional requests per reload, 40-tile viewportNo Cache-Control40max-age only40max-age + ETag40 x 304max-age + immutable0
A 304 is a cheap response but not a free one — it is still a round trip, and on a cold mobile network forty of them is most of a second.
  • 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

The one question to answer before setting immutableA decision tree separating URLs whose content can never change from those that can, and what to use for each.BEFORE MARKING IMMUTABLECan the bytes at this URL ever change?Never — the pathcarries a versionimmutable, one year, nopurge ever neededNever — the pathcarries a contenthashimmutable, one yearRarely, in placeShort max-age with an ETagOn every deployShort max-age; immutablewould pin a stale buildfor a year

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

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.