Clearing Browser Tile Cache After Python Data Updates

Part of the Cache Invalidation Strategies guide.

Operative rule: append a deterministic hash of your source data as a ?v= query parameter to every tile URL — browsers treat the mutated URL as a completely new cache entry and fetch fresh tiles automatically, with no user intervention required.

How It Works

Web mapping libraries cache raster (.png) and vector (.mvt) tile responses keyed on the exact URL string. When your Python pipeline regenerates GeoTIFFs, MBTiles, or PostGIS materialized views, the binary content of those tiles changes — but the URL (/tiles/{z}/{x}/{y}.png) stays identical. Unless the server signals a change, every browser that previously loaded that map will continue serving the cached version until the max-age directive expires. For real-time dashboards and agency-facing geo-apps, this creates a visible lag between backend data freshness and what users actually see.

The versioned URL pattern solves this by deriving a short fingerprint from the data itself — a SHA-256 digest of the PostGIS query result, a checksum of the output .mbtiles file, or an mtime-based token from the generation script — and injecting it into the tile URL template at load time. The frontend fetches the current fingerprint from a lightweight /api/tile-version endpoint (served with Cache-Control: no-cache so it is never stale) and appends it as a query parameter. Because the parameter is part of the URL string, the browser’s HTTP cache treats it as a separate entry and issues a fresh request. Conversely, tiles whose data has not changed continue to hit the cache under the same versioned URL, so network load does not increase. This approach integrates directly into Data Refresh & Automation Pipelines — the fingerprint is computed as the final step of your Python generation job, so the frontend learns about new data the next time it polls the version endpoint.

The diagram below shows the end-to-end flow from Python data generation through HTTP caching to the browser map layer.

Versioned tile cache invalidation flow Data flows from Python pipeline through a version endpoint to the browser, which appends the version hash to tile requests. Versioned tile URLs are served with immutable cache headers; the version endpoint is served with no-cache. Python Pipeline computes data hash /api/tile-version Cache-Control: no-cache Tile Server /tiles/{z}/{x}/{y}.png?v=… Browser / Map Leaflet / MapLibre GL writes hash writes tiles polls version ?v=a3f9c2 → cache miss immutable, max-age=31536000

Production-Ready Implementation

The implementation has two parts: a Python (FastAPI) backend that exposes a version endpoint and serves tiles, and a JavaScript frontend that fetches the version and injects it into the tile URL template.

# tile_server.py — FastAPI tile server with deterministic versioning
# Requirements: fastapi, uvicorn, aiofiles (pip install fastapi uvicorn aiofiles)

from fastapi import FastAPI, Query, HTTPException
from fastapi.responses import FileResponse, JSONResponse
import hashlib
import os
import struct

app = FastAPI()

TILE_ROOT = "./tiles"         # directory containing {z}/{x}/{y}.png
VERSION_FILE = "./tile_version.txt"  # written by your Python data pipeline


def compute_version() -> str:
    """
    Read the version token written by the data pipeline.
    Fallback: hash the mtime of the tile root so a manual rebuild still invalidates cache.
    Never use random bytes or time.time() — identical data must produce the same token.
    """
    if os.path.exists(VERSION_FILE):
        with open(VERSION_FILE) as f:
            return f.read().strip()[:12]
    # Deterministic fallback from directory mtime
    raw = struct.pack("d", os.path.getmtime(TILE_ROOT))
    return hashlib.sha256(raw).hexdigest()[:8]


# Cache the version at startup; reload on SIGHUP or via /admin/reload if needed
_CURRENT_VERSION: str = compute_version()


@app.get("/api/tile-version")
def get_tile_version() -> JSONResponse:
    """
    Always re-read so hot-deployed pipelines are visible without a server restart.
    no-cache tells the browser to revalidate on every poll — cheap (304) if unchanged.
    """
    version = compute_version()
    response = JSONResponse({"version": version})
    response.headers["Cache-Control"] = "no-cache, must-revalidate"
    return response


@app.get("/tiles/{z}/{x}/{y}.png")
def serve_tile(
    z: int,
    x: int,
    y: int,
    v: str | None = Query(default=None),
) -> FileResponse:
    """
    The `v` parameter is the cache-buster; tile routing ignores its value.
    Versioned URLs get immutable long-term caching headers — CDNs and browsers
    can serve them from memory indefinitely until the version changes.
    """
    tile_path = os.path.join(TILE_ROOT, str(z), str(x), f"{y}.png")
    if not os.path.exists(tile_path):
        raise HTTPException(status_code=404, detail="Tile not found")

    response = FileResponse(tile_path, media_type="image/png")
    if v:
        # Versioned request → immutable, safe to cache forever
        response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
    else:
        # Unversioned fallback (e.g., debug tool) → short TTL
        response.headers["Cache-Control"] = "public, max-age=60"
    return response
# pipeline_step.py — run this at the end of your Python data generation job
# so the frontend picks up the new version on its next poll

import hashlib
import subprocess
import pathlib

TILE_ROOT = pathlib.Path("./tiles")
VERSION_FILE = pathlib.Path("./tile_version.txt")


def write_tile_version() -> str:
    """
    Compute a deterministic fingerprint from the generated tile tree.
    Walking all tile bytes is thorough but slow for large tile sets;
    hashing only the directory mtimes is faster and sufficient for most pipelines.
    """
    hasher = hashlib.sha256()
    for p in sorted(TILE_ROOT.rglob("*.png")):
        # Include path (so adding/removing tiles changes the hash) and mtime
        hasher.update(str(p.relative_to(TILE_ROOT)).encode())
        hasher.update(str(p.stat().st_mtime_ns).encode())
    version = hasher.hexdigest()[:12]
    VERSION_FILE.write_text(version)
    print(f"Tile version written: {version}")
    return version


if __name__ == "__main__":
    write_tile_version()
// map-init.js — Leaflet frontend that polls the version endpoint
// and swaps the tile layer when the data changes

const VERSION_POLL_INTERVAL_MS = 30_000; // 30 s; tune to your pipeline frequency

async function fetchTileVersion() {
  const res = await fetch('/api/tile-version', { cache: 'no-store' });
  if (!res.ok) throw new Error(`Version endpoint returned ${res.status}`);
  const { version } = await res.json();
  return version;
}

function buildTileLayer(version) {
  return L.tileLayer(`/tiles/{z}/{x}/{y}.png?v=${version}`, {
    maxZoom: 18,
    attribution: '© OpenStreetMap contributors',
  });
}

async function initMap() {
  const map = L.map('map').setView([40.7128, -74.0060], 10);
  let version = await fetchTileVersion();
  let tileLayer = buildTileLayer(version).addTo(map);

  // Poll for version changes without reloading the whole page
  setInterval(async () => {
    try {
      const newVersion = await fetchTileVersion();
      if (newVersion !== version) {
        console.log(`Tile version changed: ${version}${newVersion}`);
        map.removeLayer(tileLayer);
        tileLayer = buildTileLayer(newVersion).addTo(map);
        version = newVersion;
      }
    } catch (err) {
      // Fail silently — stale tiles are better than a broken map
      console.warn('Version poll failed, keeping existing tiles:', err);
    }
  }, VERSION_POLL_INTERVAL_MS);
}

initMap();

Alternative Variants

MapLibre GL JS

MapLibre GL does not use L.tileLayer — update the source URL and call map.style.sourceCaches to flush:

async function swapMapLibreTiles(map, newVersion) {
  const sourceId = 'custom-raster';
  map.getSource(sourceId).setTiles([
    `/tiles/{z}/{x}/{y}.png?v=${newVersion}`
  ]);
  // MapLibre automatically re-requests tiles when the source URL changes
}

Version delivery via ETag instead of query parameter

If you control the tile server completely and want to avoid query parameters (some CDN configs strip them), use an ETag approach: serve the current data hash as the ETag header on the tile response and instruct the browser to revalidate with Cache-Control: no-cache. The browser issues If-None-Match conditional requests; when the ETag changes, the server responds with a 200 and fresh tile bytes. This is lower overhead per tile than polling a version endpoint, but requires the CDN to forward If-None-Match headers — verify your CDN cache policy before adopting it.

Strategy Cache-busting mechanism CDN-friendly Requires version endpoint
?v= query parameter URL mutation Yes (full URL key) Yes
ETag + conditional requests Header negotiation Requires config No
Short max-age (e.g. 60 s) TTL expiry Yes No
Service Worker cache JS-managed cache N/A (client only) Optional

The ?v= query parameter approach is the safest default because it works across all CDNs and browsers without special configuration and integrates cleanly with Scheduled Map Rebuild Workflows.

Verification Steps

  • Open browser DevTools → Network tab → filter by .png or .mvt. After a pipeline run, the next tile request must show the new ?v= value and a 200 status (not 304).
  • Confirm the /api/tile-version response has Cache-Control: no-cache, must-revalidate in its response headers.
  • Confirm versioned tile responses (?v= present) carry Cache-Control: public, max-age=31536000, immutable.
  • Check that tile_version.txt is updated by your pipeline script — cat ./tile_version.txt before and after a manual pipeline run should produce different hashes.
  • In Leaflet, open the browser console and verify the “Tile version changed” log fires after you manually mutate tile_version.txt and wait one poll interval.

Common Errors & Fixes

304 Not Modified returned on first load after a pipeline run

Root cause: the version endpoint itself is being cached by the browser or a proxy. Check the response headers — if Cache-Control is missing or set to max-age > 0, the browser reuses the previous response. Fix: ensure the FastAPI route explicitly sets Cache-Control: no-cache, must-revalidate on every /api/tile-version response. Also pass { cache: 'no-store' } to the fetch() call in the frontend.

Hash changes on every server restart even when data has not changed

Root cause: the version is derived from a non-deterministic source such as os.urandom(), time.time(), or process startup time rather than the data content. Fix: replace with a content-addressable hash — either hashlib.sha256() over the tile bytes/mtimes (as in the pipeline snippet above) or a database checksum such as SELECT md5(string_agg(md5(data::text), '')) over your PostGIS source table.

CDN serves old tiles despite the updated ?v= parameter

Root cause: the CDN cache key is configured to ignore query parameters. This is common on Cloudflare (default “Ignore Query String” cache rule) and on AWS CloudFront with a cache behaviour that does not forward the v parameter. Fix: add a cache policy that includes v in the query string. In Cloudflare, create a Cache Rule with “Cache Key → Query String → Include → v”. In CloudFront, create a Cache Policy with v in the query string allowlist.

Map tiles go blank during the layer swap in Leaflet

Root cause: map.removeLayer(oldLayer) is called before the new layer has any loaded tiles, leaving a momentary gap. Fix: add the new layer first and only remove the old one after the new layer fires its load event:

const newLayer = buildTileLayer(newVersion);
newLayer.once('load', () => map.removeLayer(tileLayer));
newLayer.addTo(map);
tileLayer = newLayer;