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.
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.
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.
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-Controlentirely; a dashboard that registers one has a fourth cache whose policy lives in JavaScript. immutableis honoured during the freshness window only; oncemax-ageexpires the browser revalidates like any other object, which is why the year-long lifetime matters.- Setting
no-storerather thanno-cacheon 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-Controlwhen none is supplied, and that default is rarely what a map deployment wants; set it explicitly on every upload. - Range requests and
stale-while-revalidateinteract 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.
Related
- Cache Invalidation Strategies for Geo-Dashboards — the parent guide covering all four cache layers
- Versioning Tile URLs with Content Hashes for Cache-Busting — the naming scheme these headers assume
- Purging Cloudflare CDN Tile Cache from a Python Pipeline — what to do when a name cannot change
- Serving PMTiles from Object Storage Without a Tile Server — the range headers that must survive the edge