Part of the Cache Invalidation Strategies guide.
Operative rule: make each tile URL immutable by embedding a content hash in its path (/tiles/v{hash}/{z}/{x}/{y}.mvt) and serving it with Cache-Control: public, max-age=31536000, immutable — new data always arrives under a brand-new URL, so no cache ever needs to be told a URL went stale.
How Content-Hashed URLs Work
There are two ways to defeat a stale cache: tell every cache layer that an existing URL changed, or change the URL so the old one is simply never requested again. The first approach — active invalidation — is what an edge purge does, and it is inherently racy: you must reach every browser and every CDN node, and any you miss keeps serving old bytes. The second approach flips the problem on its head. If the URL itself encodes a fingerprint of the data, then changed data produces a changed URL, and a changed URL is a guaranteed cache miss everywhere at once. Nobody has to be told anything.
Concretely, you compute a short hash of the source dataset in Python during the rebuild, write the tile tree under a versioned prefix such as /tiles/va3f9c2/, and rewrite the map’s MapLibre style JSON so the tile source tiles array points at the new prefix. When the browser loads the updated style, it requests /tiles/va3f9c2/12/2103/1345.mvt — a URL it has never seen — and gets fresh bytes. The previous version’s tiles still exist at their old prefix and can be garbage-collected later. Because each versioned URL maps to exactly one immutable body, you can mark it immutable and cache it for a year, eliminating revalidation traffic entirely. This is the durable backbone of Cache Invalidation Strategies; an edge purge such as purging the Cloudflare CDN tile cache becomes a cleanup tool for the old prefix rather than a race you have to win.
Production-Ready Implementation
The utility below computes a deterministic hash of the source dataset, plans a versioned output prefix, and rewrites a MapLibre GL style JSON in place so its vector source points at the new tiles. Run it after tiles are generated but before you publish the style — the style rewrite is the atomic switch that makes the new version live.
# tile_versioning.py — derive a content hash, version the tile prefix, rewrite the style
# Requires: only the standard library.
from __future__ import annotations
import hashlib
import json
import shutil
from pathlib import Path
def content_hash(dataset: Path, *, length: int = 7) -> str:
"""
Deterministic short hash of the source dataset.
Hash the *input* that determines the tiles, never a timestamp — identical
data must yield an identical version so unchanged rebuilds are no-ops.
"""
hasher = hashlib.sha256()
with dataset.open("rb") as fh:
for block in iter(lambda: fh.read(1 << 20), b""): # stream in 1 MiB blocks
hasher.update(block)
return "v" + hasher.hexdigest()[:length]
def publish_versioned_tiles(
built_tiles: Path, tiles_root: Path, version: str
) -> Path:
"""
Move freshly built tiles into /tiles/<version>/. Idempotent: if the version
directory already exists, the data was unchanged and we skip the copy.
"""
target = tiles_root / version
if target.exists():
return target # same hash → already published
shutil.copytree(built_tiles, target) # copy so the old version survives for rollback
return target
def rewrite_style(style_path: Path, source_id: str, base_url: str, version: str) -> None:
"""
Point a MapLibre GL vector source at the versioned tile prefix and mark it
volatile:false so the client keeps tiles across the session.
"""
style = json.loads(style_path.read_text())
source = style["sources"][source_id]
source["tiles"] = [f"{base_url}/tiles/{version}/{{z}}/{{x}}/{{y}}.mvt"]
# A stable source URL under a versioned path is safe to cache aggressively.
style_path.write_text(json.dumps(style, indent=2))
def main() -> None:
dataset = Path("data/incidents.geojson")
built = Path("build/tiles") # output of tippecanoe / your tiler
tiles_root = Path("public/tiles")
style_path = Path("public/style.json")
version = content_hash(dataset)
published = publish_versioned_tiles(built, tiles_root, version)
rewrite_style(style_path, source_id="incidents", base_url="https://cdn.example.com",
version=version)
print(f"Published {published} and pinned style to {version}")
if __name__ == "__main__":
main()
The server that hosts public/tiles/ must send the immutable header for versioned paths. In Nginx that is a single location block:
# Versioned tile prefix — safe to cache for a year because the URL is immutable.
location ~ ^/tiles/v[0-9a-f]+/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# The style points at the current version and must revalidate.
location = /style.json {
add_header Cache-Control "no-cache, must-revalidate";
try_files $uri =404;
}
Alternative Variants
Hash the built tile archive instead of the raw source
When several source files feed one tile set, hash the built .mbtiles file rather than every input — a single artifact hash still changes whenever any input changes, and it is cheaper to compute:
def mbtiles_version(mbtiles: Path, *, length: int = 7) -> str:
"""Version derived from the built MBTiles artifact — one hash for many inputs."""
return "v" + hashlib.sha256(mbtiles.read_bytes()).hexdigest()[:length]
Prefix versioning versus query-string versioning
| Aspect | Path prefix /tiles/v{hash}/ | Query string ?v={hash} |
|---|---|---|
| CDN cache key stability | Always distinct resource | May be stripped by config |
immutable header safe? |
Yes | Only if key includes the param |
| Old versions coexist | Yes — separate directories | No — same path, overwritten |
| Rollback | Repoint style at old prefix | Not possible |
| Cleanup | Delete old prefix directory | N/A |
Prefix versioning is the stronger default: the immutable guarantee holds unconditionally and rollback is a one-line style edit. The query-string form appears in clearing the browser tile cache, which is a lighter-weight option when you cannot restructure the tile paths.
Verification Steps
- Run the pipeline twice on identical input and confirm the version string is byte-for-byte the same both times — a differing hash means a timestamp leaked into the input.
- Request a versioned tile with
curl -sIand confirm the response carriesCache-Control: public, max-age=31536000, immutable. - Fetch
style.jsonand confirm its sourcetilesarray contains the current version prefix, and that it is served withno-cache. - Change one feature in the source dataset, rebuild, and confirm the version prefix changed and the browser requests the new path (visible in DevTools → Network).
- Confirm the previous version directory still exists on disk so a rollback is possible before you prune it.
Common Errors & Fixes
Version changes on every build even with unchanged data
Something non-deterministic is in the hash input — a build timestamp baked into the file, a directory-order-dependent walk, or floating-point coordinates re-serialised with different precision. Hash a canonical, stable artifact: the exact source bytes, or the built .mbtiles file. If you must hash a directory, sort the file list and include relative paths only, never mtimes.
Style still points at the old tiles after a rebuild
The style rewrite step did not run, or it wrote to a copy that is not the one being served. Confirm rewrite_style targets the deployed style.json and that the deploy publishes it after the versioned tiles exist. The style switch must be the last thing to go live so a client never loads a style referencing tiles that are not yet uploaded.
Old tile versions accumulate and fill the disk
Every rebuild leaves the previous prefix in place for rollback, which is correct, but without cleanup the directory grows unbounded. Add a retention step that keeps the current version plus the last one or two, then deletes older prefixes. Pair prefix deletion with a purge-by-prefix call so the edge drops the retired tiles too.
Browser caches the style JSON and never sees the new version
The style was served with a long max-age like the tiles. The style is the one mutable document in this scheme and must be served no-cache, must-revalidate. Only the versioned tile paths are immutable; the style that points at them must always be revalidated.
Related
- Cache Invalidation Strategies — parent guide covering edge, browser, and immutable-URL invalidation for geo-dashboards
- Purging Cloudflare CDN Tile Cache from a Python Pipeline — how to retire the old version prefix at the edge after switching
- Clearing Browser Tile Cache After Python Data Updates — the query-string alternative when you cannot restructure tile paths
- Tile vs Vector Rendering Strategies — how your rendering choice shapes the tile URL structure you version