Setting Cache-Control Headers for Tiles and GeoJSON

Part of the Cache Invalidation Strategies guide.

Operative rule: caching policy follows the naming scheme — content-hashed names get an immutable year, mutable names get revalidation, and no asset gets a long lifetime unless its URL changes when its bytes do.

One Policy per Asset Class

A map deployment publishes four kinds of object and they want four different policies. Applying one blanket rule is what produces either a stale map that cannot be fixed or an origin that is hit on every pan.

Cache policy by asset class Content-hashed tiles and archives get a one-year immutable lifetime. The manifest gets no-cache with revalidation because it must never be stale. Unhashed data files get a short lifetime with stale-while-revalidate. The page shell gets a short lifetime with revalidation so a deploy is picked up quickly. Four classes, four policies — never one blanket rule hashed tiles / archives tiles.a91c4f.pmtiles public, max-age=31536000, immutable safe because a new build writes a new name — nothing is ever purged the manifest manifest.json no-cache — revalidate on every load a few hundred bytes; the one object that must never be stale unhashed data files regions.geojson max-age=60, s-maxage=600, stale-while-revalidate=3600 instant paint from a stale copy, refreshed in the background the page shell no-cache — so a deploy is visible on the next load, not in an hour

Production-Ready Implementation

from __future__ import annotations

import re
from pathlib import Path

YEAR = 31_536_000

RULES: list[tuple[re.Pattern[str], str]] = [
    # Content-hashed: the name changes when the bytes change.
    (re.compile(r"\.[0-9a-f]{8,}\.(pmtiles|mbtiles|pbf|json|geojson)$"),
     f"public, max-age={YEAR}, immutable"),
    # Tiles under a versioned prefix are equally immutable.
    (re.compile(r"^tiles/v/[0-9a-f]{8,}/"),
     f"public, max-age={YEAR}, immutable"),
    # The pointer objects — never stale.
    (re.compile(r"(^|/)(manifest|latest)\.json$"),
     "public, max-age=0, must-revalidate"),
    # Unhashed data — short browser life, longer edge life, serve stale while fresh.
    (re.compile(r"\.(geojson|json)$"),
     "public, max-age=60, s-maxage=600, stale-while-revalidate=3600"),
    # Fonts and sprites change only with the style.
    (re.compile(r"^(fonts|sprite)/"),
     f"public, max-age={YEAR}, immutable"),
    # HTML shell.
    (re.compile(r"\.html$"), "public, max-age=0, must-revalidate"),
]

DEFAULT = "public, max-age=300"


def cache_control_for(path: str) -> str:
    for pattern, value in RULES:
        if pattern.search(path):
            return value
    return DEFAULT


def upload_plan(root: Path) -> list[tuple[str, str]]:
    """(key, cache-control) for every file to publish — assert this in review."""
    plan = []
    for file in sorted(root.rglob("*")):
        if file.is_file():
            key = str(file.relative_to(root))
            plan.append((key, cache_control_for(key)))
    return plan


if __name__ == "__main__":
    for key, header in upload_plan(Path("dist")):
        print(f"{header:<62} {key}")

Printing the plan is worth the four lines. Cache headers are invisible until they are wrong, and a reviewable list showing which policy each file receives catches the file that fell through to the default long before a reader does.

Splitting Browser and Edge Lifetimes

max-age governs the browser, which you cannot purge; s-maxage governs shared caches, which you can. Giving the edge a much longer lifetime keeps origin traffic low while limiting how long any individual reader can hold something you would need to correct.

Browser lifetime versus edge lifetime The browser cache obeys max-age and cannot be purged, so its lifetime should stay short for anything unhashed. The edge cache obeys s-maxage, can be purged by API and should have a long lifetime to keep origin traffic low. Stale-while-revalidate lets the edge answer instantly while refreshing behind the request. Two lifetimes in one header, for two very different caches max-age → the browser cannot be purged, only outlived keep short for anything unhashed a year is correct only for hashed names this is the one you will regret setting too high s-maxage → the edge purgeable by API when needed longer is better — it protects the origin pair with stale-while-revalidate readers get an instant answer while it refreshes A tile with max-age of a year and no hash in its name is an unfixable mistake for every reader who fetched it. Purging the edge does not reach them; only a new URL does.

ETags, Range Requests and Compression

Three secondary headers change behaviour more than their obscurity suggests. A stable ETag makes revalidation cheap — a 304 with no body instead of a full transfer — but must be stable across replicas, or every request looks changed. Accept-Ranges must survive whatever proxy sits in front of a single-file archive, or range serving breaks. And compression should be applied to GeoJSON and style documents but not to already-compressed tile payloads, where it costs CPU and saves nothing.

Reading a response and knowing whether it is right

Most cache bugs are diagnosed by looking at four values on a single response. Knowing what each one should say for the asset class in question turns a vague “the map is stale” into a specific answer in under a minute, without instrumenting anything.

Four headers that diagnose a caching problem Cache-Control states the intended policy and whether it survived the proxy. ETag shows whether revalidation can be cheap. Age reveals how long the edge has held the object. The provider's cache status header distinguishes a hit from a miss, which separates an edge problem from an origin one. Four values, read from one response Cache-Control is it the policy you set, or one a proxy rewrote? ETag present and stable → revalidation is a 304, not a full transfer Age how long the edge has held this copy — the staleness, in seconds cache status header hit or miss — separates an edge problem from an origin one

Print the plan on every deploy and keep the output in the build log. A header policy that is visible in a diff is one somebody can review; a policy applied by a default nobody remembers setting is one that is discovered years later, usually during an incident.

Verification Steps

  • Fetch every asset class once and print the response headers; compare them against the plan the build printed.
  • Reload with the network panel open and confirm hashed assets are served from cache with no request at all.
  • Confirm the manifest produces a conditional request on every load.
  • Confirm a GeoJSON file returns 304 on revalidation rather than a full body.
  • After a deploy, confirm a reader on the previous build picks up the new manifest on their next load.

Common Errors & Fixes

The map updates for some readers and not others

An unhashed asset has a long max-age. Those browsers will not check again until it expires. Move to hashed names; there is no other reliable fix.

The origin is hit on every tile request

s-maxage is missing or the response carries private. Confirm the edge sees a cacheable response, and that no authentication header is forcing it to bypass.

Revalidation transfers the whole file every time

The ETag differs between replicas or is regenerated per request. Derive it from the content hash the build already computes.

A stale tile survives a purge

The reader’s browser cache still holds it. Edge purges do not reach browsers — which is the whole argument for content-hashed names.

Vary, Compression and the Headers That Fragment a Cache

Two response headers quietly multiply the number of cache entries an object occupies, and both appear in map deployments more often than anyone intends.

Vary is the first. A response that varies on Accept-Encoding occupies one entry per encoding, which is normal and fine. A response that varies on Origin — which some CORS configurations add automatically — occupies one entry per requesting origin, and a dashboard embedded on several hosts therefore multiplies its own cache footprint and its origin traffic with it. If the allowed origins are a small fixed set, prefer a static allow list that does not require varying at all.

The second is the query string. Many edges include the full query in the cache key by default, so a tile URL carrying a cache-busting parameter, an analytics tag or a session identifier becomes a distinct object per unique value. That is exactly the pathology content-hashed path segments avoid, and it is worth checking rather than assuming: a single stray parameter appended by a client library can reduce a hit rate from ninety-something per cent to nearly zero without any other symptom.

Compression deserves a deliberate decision per type. Vector tiles are usually already compressed and re-compressing them costs CPU at the edge for no benefit. GeoJSON, style documents and manifests compress extremely well and should always be served compressed. The practical approach is an explicit list rather than a blanket rule, because “compress everything” and “compress nothing” are both wrong in a map deployment.

Finally, confirm that whatever sits in front of the origin actually forwards the headers you set. Some proxies rewrite Cache-Control, some strip ETag, and a few normalise Accept-Ranges away entirely — which breaks single-file archives in a way that looks like a client bug. Fetching each asset class through the full production path, rather than directly from storage, is the only way to know what readers really receive.

Gotchas & Edge Cases

  • A service worker caches according to its own logic and ignores Cache-Control entirely; a dashboard that registers one has a fourth cache whose policy lives in JavaScript.
  • immutable is honoured during the freshness window only; once max-age expires the browser revalidates like any other object, which is why the year-long lifetime matters.
  • Setting no-store rather than no-cache on the manifest defeats conditional requests and forces a full transfer every load — a small file, but an unnecessary one.
  • Object storage often applies a default Cache-Control when none is supplied, and that default is rarely what a map deployment wants; set it explicitly on every upload.
  • Range requests and stale-while-revalidate interact badly on some edges, which is one more reason to keep single-file archives immutable and versioned by name.
  • Headers set at upload time are stored with the object, so changing a policy means re-uploading — worth knowing before publishing forty thousand tiles with the wrong lifetime.