Caching Vector Tiles at the Edge with Cache-Control

The exact Cache-Control strategy for vector tiles: immutable versioned tiles vs mutable tiles, s-maxage, stale-while-revalidate, ETag, and purge-on-publish via a version segment in the tile path — with runnable FastAPI header code.

← 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

Cache-Control lifecycle for immutable versus mutable tilesThe top timeline shows an immutable versioned tile: fresh for one year, no revalidation, then a version bump replaces the URL. The bottom timeline shows a mutable tile: fresh during s-maxage, then a stale-while-revalidate window where the stale tile is served instantly while the edge revalidates in the background, then a hard revalidation.Immutable · /tiles/v42/roads/10/512/340.mvtFRESH — served from edge, no revalidation (max-age = s-maxage = 1 year, immutable)version bump → v43new URL, new keyMutable · /tiles/roads/10/512/340.mvtFRESH (s-maxage 300s)STALE-WHILE-REVALIDATE (86400s)serve stale instantly · refresh in backgroundmust revalidateETag / 304 or refetcht = 0time →Immutable maximises hit ratio; mutable trades a freshness window for instant, never-blocking responses.

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

DirectiveMeaningImmutable tileMutable tile
publicShared caches (edge/CDN) may store the responseYesYes
max-age=NSeconds the browser treats the tile as fresh31536000 (1 yr)0
s-maxage=NSeconds the shared/edge cache treats it as fresh; overrides max-age for the edge31536000300
immutableBrowser skips revalidation within max-age even on reloadSet itOmit
stale-while-revalidate=NServe stale for N s while refreshing in backgroundOmit (never stale)86400
ETagValidator for cheap 304 Not Modified revalidationOptionalRecommended
Vary: Accept-EncodingSeparate cache entries for gzip vs identityAlwaysAlways
Version path segmentTurns 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 only max-age=0 intending “browser always revalidates”, the edge inherits max-age=0 and caches nothing — your origin sees every request. Conversely, max-age=31536000 with no s-maxage lets the browser pin a stale mutable tile for a year. Always set both explicitly; they control different caches.

  • Caching error responses. A 403, 404, or 500 served with a long-lived Cache-Control freezes the failure into the edge. A transient origin error then becomes a year-long outage for that tile. Only attach caching headers to 200 responses; send Cache-Control: no-store on error paths.

  • Missing Vary: Accept-Encoding corrupts clients. If the edge caches a gzip-compressed tile and later serves it to a client that did not send Accept-Encoding: gzip, the client receives compressed bytes it cannot parse — the MVT decode fails with a garbled-protobuf error. Vary: Accept-Encoding keeps compressed and identity variants in separate cache entries.

  • immutable on a mutable URL. Marking a stable-URL tile immutable tells browsers never to revalidate, so a data update is invisible until the max-age expires — potentially a year. immutable is 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 though v43 exists. Serve the tile URL template from the same version source and give the style document a short s-maxage so clients pick up the new template quickly.

  • stale-while-revalidate on 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: 0

A 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.


← Back to Edge Routing & Tile Delivery at Scale