← Back to Edge Routing & Tile Delivery at Scale
Get the Cache-Control header exactly right so immutable versioned tiles cache for a year, mutable tiles revalidate without a latency stall, and a data publish invalidates the edge by changing the URL rather than purging it.
Context & when to use
The single header that decides your edge hit ratio is Cache-Control. Send it wrong and either the edge over-caches stale geometry for a year, or it revalidates on every request and your ST_AsMVT origin melts. There are exactly two cases, and they need different headers: a versioned tile whose URL contains a data-version segment (/tiles/v42/...) is immutable — its bytes can never change under that URL — and a mutable tile at a stable URL (/tiles/roads/...) that must reflect data updates within some freshness window.
Use immutable caching whenever you can put a version in the path, which the delivery architecture in Edge Routing & Tile Delivery at Scale is built around — it gives the highest hit ratio and the cleanest invalidation. Use mutable caching with stale-while-revalidate only when you cannot version the URL (for example, a third-party consumer hardcoded a stable tile template). This page is the header-level companion to the Cloudflare Workers edge router, which attaches these exact directives to tiles it stores, and to the origin query in Tile Generation & CDN Distribution.
Directive timeline: immutable vs mutable
Runnable implementation
A FastAPI helper that returns the right headers for each case, plus the purge-on-publish routine. The version comes from the path for immutable tiles; mutable tiles carry an ETag so the edge can revalidate cheaply with a conditional request.
# app/tiles/headers.py
import hashlib
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import Response
router = APIRouter()
def immutable_tile_headers(version: int, layer: str, z: int, x: int, y: int) -> dict:
"""Versioned URL → bytes can never change → cache for a year, never revalidate."""
return {
# public: shared caches (the CDN/edge) may store it
# max-age: browser holds it for 1 year
# s-maxage: shared cache (edge) holds it for 1 year — REQUIRED, or the
# browser's max-age also caps the edge on some CDNs
# immutable: browser skips revalidation entirely within max-age
"Cache-Control": "public, max-age=31536000, s-maxage=31536000, immutable",
"ETag": f'"{version}-{layer}-{z}-{x}-{y}"',
"Vary": "Accept-Encoding", # never serve gzip bytes to an identity client
"Content-Type": "application/vnd.mapbox-vector-tile",
}
def mutable_tile_headers(tile_bytes: bytes) -> dict:
"""Stable URL → may change → short fresh window, then serve stale while revalidating."""
etag = hashlib.sha1(tile_bytes).hexdigest()[:16]
return {
# s-maxage=300: edge treats the tile as fresh for 5 minutes
# stale-while-revalidate: for the next 24h the edge serves the STALE tile
# instantly and refreshes it in the background —
# no client ever waits on the origin
# (no 'immutable' here: the URL is stable, so revalidation must be allowed)
"Cache-Control": "public, max-age=0, s-maxage=300, stale-while-revalidate=86400",
"ETag": f'"{etag}"',
"Vary": "Accept-Encoding",
"Content-Type": "application/vnd.mapbox-vector-tile",
}
@router.get("/tiles/{version}/{layer}/{z}/{x}/{y}.mvt")
async def versioned_tile(request: Request, version: int, layer: str,
z: int, x: int, y: int):
if not (0 <= z <= 15 and 0 <= x < 2 ** z and 0 <= y < 2 ** z):
raise HTTPException(status_code=400, detail="Invalid tile coordinates")
tile_bytes = await generate_mvt(request.app.state.pool, version, layer, z, x, y)
# Honour conditional requests so a revalidation costs 304, not a full body
etag = f'"{version}-{layer}-{z}-{x}-{y}"'
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers={"ETag": etag})
return Response(
content=tile_bytes,
headers=immutable_tile_headers(version, layer, z, x, y),
)Purge-on-publish never enumerates tile paths. It bumps the version and, as a belt-and-braces measure, fires a tag-based purge:
# app/tiles/publish.py
import httpx, os
async def publish_new_tile_version(pool) -> int:
"""Atomically bump the data version. New tile URLs miss the edge and repopulate;
old versioned URLs age out by TTL. No per-path purge storm."""
async with pool.acquire() as conn:
new_version = await conn.fetchval(
"UPDATE tile_dataset SET version = version + 1 "
"WHERE name = 'roads' RETURNING version"
)
# Optional emergency purge of the LAYER tag (Cloudflare Cache-Tag / Fastly
# Surrogate-Key) in case a bad tile was cached under the previous version.
async with httpx.AsyncClient() as client:
await client.post(
f"https://api.cloudflare.com/client/v4/zones/{os.environ['CF_ZONE']}/purge_cache",
headers={"Authorization": f"Bearer {os.environ['CF_TOKEN']}"},
json={"tags": ["layer:roads"]},
)
return new_version # expose this in the map style JSON so clients request /tiles/{new}/...Key parameters & options
| Directive | Meaning | Immutable tile | Mutable tile |
|---|---|---|---|
public | Shared caches (edge/CDN) may store the response | Yes | Yes |
max-age=N | Seconds the browser treats the tile as fresh | 31536000 (1 yr) | 0 |
s-maxage=N | Seconds the shared/edge cache treats it as fresh; overrides max-age for the edge | 31536000 | 300 |
immutable | Browser skips revalidation within max-age even on reload | Set it | Omit |
stale-while-revalidate=N | Serve stale for N s while refreshing in background | Omit (never stale) | 86400 |
ETag | Validator for cheap 304 Not Modified revalidation | Optional | Recommended |
Vary: Accept-Encoding | Separate cache entries for gzip vs identity | Always | Always |
| Version path segment | Turns invalidation into a URL change | /tiles/v42/... | not available |
s-maxage is the directive teams most often forget, and its absence is the classic bug: without it the edge falls back to max-age, so if you set max-age=0 for “always revalidate in the browser” you accidentally also stop the edge from caching. Always set s-maxage explicitly for the edge behaviour you want, independent of the browser’s max-age.
Gotchas & failure modes
Missing
s-maxage, so browsers over-cache (or the edge under-caches). If you send onlymax-age=0intending “browser always revalidates”, the edge inheritsmax-age=0and caches nothing — your origin sees every request. Conversely,max-age=31536000with nos-maxagelets the browser pin a stale mutable tile for a year. Always set both explicitly; they control different caches.Caching error responses. A
403,404, or500served with a long-livedCache-Controlfreezes the failure into the edge. A transient origin error then becomes a year-long outage for that tile. Only attach caching headers to200responses; sendCache-Control: no-storeon error paths.Missing
Vary: Accept-Encodingcorrupts clients. If the edge caches a gzip-compressed tile and later serves it to a client that did not sendAccept-Encoding: gzip, the client receives compressed bytes it cannot parse — the MVT decode fails with a garbled-protobuf error.Vary: Accept-Encodingkeeps compressed and identity variants in separate cache entries.immutableon a mutable URL. Marking a stable-URL tileimmutabletells browsers never to revalidate, so a data update is invisible until themax-ageexpires — potentially a year.immutableis only correct when the version is in the URL.Version bumped but the style JSON still points at the old version. Clients keep requesting
/tiles/v42/...and see stale tiles even thoughv43exists. Serve the tile URL template from the same version source and give the style document a shorts-maxageso clients pick up the new template quickly.stale-while-revalidateon a first-ever request. SWR only helps once a tile is already cached; the very first request per colo still blocks on the origin. This is expected — SWR removes the stall on subsequent refreshes, not the cold miss.
Verification
Confirm the headers and the fresh → stale → revalidate transition with curl -I and the age header:
# Immutable versioned tile: year-long, immutable, with an ETag
curl -sI "https://cdn.example.com/tiles/42/roads/10/512/340.mvt" \
| grep -iE 'cache-control|etag|age|vary'
# cache-control: public, max-age=31536000, s-maxage=31536000, immutable
# etag: "42-roads-10-512-340"
# age: 5123 ← seconds the edge has held it
# vary: accept-encoding
# Conditional revalidation returns 304 with no body
curl -sI "https://cdn.example.com/tiles/42/roads/10/512/340.mvt" \
-H 'If-None-Match: "42-roads-10-512-340"' | head -1
# HTTP/2 304
# Mutable tile: short s-maxage, long stale-while-revalidate
curl -sI "https://cdn.example.com/tiles/roads/10/512/340.mvt" | grep -i cache-control
# cache-control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
# After a publish bump, the OLD version keeps serving (immutable), the NEW misses:
curl -sI "https://cdn.example.com/tiles/43/roads/10/512/340.mvt" \
| grep -iE 'cf-cache-status|age'
# cf-cache-status: MISS
# age: 0A mutable tile whose age climbs past s-maxage while still returning 200 instantly (not a slow origin fetch) confirms stale-while-revalidate is working: the edge is serving stale bytes and refreshing in the background.
Related
- Edge Routing & Tile Delivery at Scale — the versioned tile contract and purge-on-publish strategy these headers implement
- Cloudflare Workers Edge Routing for Vector Tile Endpoints — a Worker that attaches these directives to tiles it stores in the Cache API
- Tile Generation & CDN Distribution — the
ST_AsMVTorigin and CDN caching layers that consume these headers
← Back to Edge Routing & Tile Delivery at Scale