Tile Cache Management and Invalidation for Map Servers

A production map server is only as fast as its cache hit ratio, and only as correct as its invalidation logic. Every rendered tile is expensive — vector tiles cost a database query and a geometry clip, raster tiles cost a full Mapnik or MapLibre Native render pass — so a well-fed cache is the difference between a map that serves thousands of requests per second from the edge and one that melts the render tier on the first traffic spike. The hard part is not caching; it is knowing precisely which tiles to throw away when the underlying data or style changes, and doing so without purging so aggressively that you cold-start the entire pyramid. This page treats the tile cache as a lifecycle: seed it, key it, expire it, and invalidate a bounded set of z/x/y coordinates when — and only when — something they depend on actually changed.


Prerequisites and Environment Configuration

Tile cache work sits between a renderer and a delivery network, so pin the versions of both the tiling library and the cache client explicitly. Edge behaviour differs subtly between CDN vendors, and mercantile’s tile-enumeration semantics changed across major versions.

  • Python 3.9+ with venv or conda isolation.
  • mercantile>=1.2 — the reference implementation of the XYZ/TMS tile scheme; supplies tiles(), bounds(), and xy_bounds(). Note that mercantile uses XYZ (Google/Slippy) convention where y=0 is the north edge, not TMS.
  • requests>=2.31 — for issuing CDN purge API calls with connection pooling.
  • redis>=5.0 (optional) — if your tile store is Redis rather than a filesystem tree; redis-py 5 ships native pipelining used below for batch deletes.
  • A tile origin. Any of: mod_tile/renderd backed by Mapnik, a MapProxy instance, or a custom FastAPI endpoint that renders or reads tiles. This guide is origin-agnostic — it manipulates cache keys and URLs, not the renderer.
  • A CDN with a purge API. Fastly (/purge), Cloudflare (/zones/{id}/purge_cache), or CloudFront (create_invalidation) all expose an HTTP purge-by-URL or purge-by-tag interface. You need an API token with purge scope.
  • CRS requirement: the tile scheme is Web Mercator, EPSG:3857, with the standard 256-pixel (or 512 for vector) tile grid. All bounding boxes handed to the invalidation functions below must be in EPSG:4326 lon/lat degrees, because mercantile.tiles consumes geographic coordinates and projects internally. Mixing a projected bbox into these calls silently returns the wrong tiles.

Keep the renderer’s cache-key convention and the CDN’s cache-key convention in a single shared module. The most common cause of un-purgeable tiles is two services that disagree about what string identifies a tile.

Conceptual Foundation: The Cache Pyramid and Coordinate Keys

A tile cache is a pyramid. Zoom level z holds 4^z tiles covering the whole world; each tile at zoom z splits into four children at z+1. This geometric fan-out is the single most important fact about tile invalidation: a fixed ground area maps to one tile at low zoom and exponentially more tiles at high zoom. A city-block data edit is one z12 tile and roughly 4,000 z18 tiles. Every invalidation decision is really a decision about how far up this pyramid you are willing to pay to purge.

The cache key is the coordinate triple plus everything that changes the rendered pixels for that coordinate. At minimum:

key = f"{style_version}/{z}/{x}/{y}.{ext}"

The style_version token is what makes a global restyle cheap. Because it is part of the key, bumping it from v7 to v8 instantly orphans every old tile — the new URL never matches a cached entry, so the edge treats the whole pyramid as a miss without a single per-tile purge call. This is the same versioning discipline that theme inheritance systems rely on when a base palette change must cascade to every derived map: change the token, not ten thousand objects. Data edits, by contrast, are local, so they get explicit per-coordinate purges rather than a version bump.

The reason a data edit maps to a bounded set of tiles is that Web Mercator tiling is a fixed spatial index. A changed feature occupies a bounding box in lon/lat; that box overlaps a computable rectangle of tiles at each zoom. You never have to scan the cache to find affected tiles — you derive them arithmetically from the bbox. This is what mercantile.tiles(west, south, east, north, zooms) does: it returns exactly the Tile(x, y, z) objects whose extent intersects the box.

TTL and explicit invalidation are complementary, not alternatives. TTL is a safety net for the tiles you didn’t know to purge — the edit you missed, the upstream data feed that changed silently. Explicit invalidation is the precise instrument for edits you did observe. Production systems run both: a modest TTL (minutes to hours by zoom) bounds worst-case staleness, while event-driven purges keep observed edits fresh immediately. The diagram below traces one edit through that pipeline.

Tile Invalidation Flow A changed bounding box is expanded by mercantile.tiles into an affected z/x/y tile set across a zoom range. Those keys are batched into a purge request sent to the CDN and its edge nodes, so the next client request is a cache miss that repopulates from the origin renderer. Changed bbox lon/lat 4326 mercantile Affected z/x/y z14 4× per zoom Batch purge versioned keys CDN / edge purged + TTL Client fresh tile origin render miss → repopulate

Step-by-Step Implementation

Step 1: Expand the Changed Bounding Box into Tiles

When a feature is edited, you know its geometry, and therefore its bounding box in lon/lat. The first step is turning that box into the exact tile coordinates it touches at every zoom you cache. mercantile.tiles is the correct tool because it walks the Mercator grid arithmetically rather than scanning the cache.

import mercantile

def affected_tiles(bbox, minzoom: int, maxzoom: int):
    """
    Enumerate every XYZ tile intersecting `bbox` across a zoom range.

    bbox: (west, south, east, north) in EPSG:4326 degrees.
    Yields mercantile.Tile(x, y, z) objects. The count grows ~4x per
    zoom level, so bound maxzoom deliberately.
    """
    west, south, east, north = bbox
    zooms = range(minzoom, maxzoom + 1)
    yield from mercantile.tiles(west, south, east, north, zooms)

The “why” here is that this derivation is deterministic and cheap: mercantile.tiles is O(number_of_tiles), and the number of tiles is exactly what you are about to purge — there is no wasted work. Choosing maxzoom is a policy decision, not a technical one; every extra zoom level quadruples the purge cost.

Step 2: Buffer the Box for Rendering Bleed

Features rendered near a tile edge spill into the neighbour: a label halo, a line casing, or a fat highway shield extends beyond the geometry’s own extent. If you enumerate from the tight bbox you leave those neighbour tiles stale. Buffer the box by a small margin before enumeration.

def buffer_bbox(bbox, margin_deg: float = 0.0005):
    """Grow a lon/lat bbox by a fixed degree margin to catch symbology
    that bleeds across tile boundaries (halos, casings, shields)."""
    west, south, east, north = bbox
    return (west - margin_deg, south - margin_deg,
            east + margin_deg, north + margin_deg)

A degree margin is coarse but adequate for label bleed; for pixel-exact bleed you would convert a pixel margin to degrees per zoom, but the fixed margin is simpler and errs toward over-inclusion, which is the safe direction for correctness.

Step 3: Build Versioned Cache Keys and URLs

Convert each Tile into the exact string the CDN cached. This is the single point where renderer and edge must agree. Keep the style_version in the path so that a restyle is a version bump, and data edits are per-tile purges.

def tile_url(tile, style_version: str, base: str, ext: str = "pbf") -> str:
    """Render the canonical, versioned tile URL. This string must match
    byte-for-byte what the CDN cached, or the purge is a no-op."""
    return f"{base}/{style_version}/{tile.z}/{tile.x}/{tile.y}.{ext}"

Vector tiles use .pbf (or .mvt); raster tiles use .png or .webp. The extension is part of the key — a .png purge does not clear a .webp variant of the same coordinate, so if you serve multiple formats you must purge each.

Step 4: Decide Seed-Ahead vs Lazy Fill

For each zoom, you choose between pre-rendering the new tiles immediately (seed-ahead) or simply invalidating and letting the first client request repopulate the cache (lazy fill). Seed the low zooms — they are few, universally requested, and expensive to miss under a traffic spike. Let the deep zooms fill lazily — there are millions of them and most will never be requested.

def seed_policy(zoom: int, seed_below: int = 13) -> str:
    """Low zooms are seeded ahead; deep zooms fill lazily on first request."""
    return "seed" if zoom <= seed_below else "lazy"

The break-even is request probability: a z6 tile is near-certain to be hit, so eating the render cost up front avoids a user-facing miss; a z19 tile has a vanishing hit probability, so rendering it speculatively wastes the render tier.

Step 5: Bump the Version Token on Style Changes

A style change is categorically different from a data edit. It affects every tile, so per-tile purging is the wrong tool. Increment the token and let old URLs orphan themselves.

def bump_style_version(current: str) -> str:
    """v7 -> v8. New URLs never match old cache entries, so a global
    restyle invalidates the whole pyramid with no purge calls."""
    n = int(current.lstrip("v"))
    return f"v{n + 1}"

Because Web Mercator zoom levels map directly onto print scale denominators, a restyle that changes line weights or type sizes usually needs re-validation across the whole scale range — the same zoom-to-scale relationship covered in scale mapping for web and print — which is another reason the version bump, not a partial purge, is correct for style changes.

Complete Working Code Example

The function below is the core of an event-driven invalidation service: it takes a changed bbox, enumerates the affected tiles across a zoom range, and batch-purges them through an injected CDN purge function. The cdn_purge_fn is a callable so the same logic drives Fastly, Cloudflare, or CloudFront — you supply the vendor adapter.

"""
tile_invalidation.py

Event-driven tile cache invalidation for a Web Mercator (EPSG:3857) map
server. Given a changed bounding box in lon/lat, compute the affected
XYZ tiles and batch-purge them from a CDN and an origin cache.

    mercantile == 1.2.1
    requests  == 2.31.0
    redis     == 5.0.1
"""

from __future__ import annotations

import itertools
from typing import Callable, Iterable, Iterator

import mercantile
import requests


def _chunked(iterable: Iterable[str], size: int) -> Iterator[list[str]]:
    """Yield lists of at most `size` items — CDN purge APIs cap URLs per call."""
    it = iter(iterable)
    while batch := list(itertools.islice(it, size)):
        yield batch


def affected_tiles(bbox, minzoom: int, maxzoom: int, margin_deg: float = 0.0005):
    """Every XYZ tile intersecting a buffered lon/lat bbox across a zoom range."""
    west, south, east, north = bbox
    b = (west - margin_deg, south - margin_deg,
         east + margin_deg, north + margin_deg)
    yield from mercantile.tiles(*b, range(minzoom, maxzoom + 1))


def tile_url(tile, style_version: str, base: str, ext: str = "pbf") -> str:
    """Canonical versioned tile URL. Must match the cached key byte-for-byte."""
    return f"{base}/{style_version}/{tile.z}/{tile.x}/{tile.y}.{ext}"


def invalidate_bbox(
    bbox: tuple[float, float, float, float],
    minzoom: int,
    maxzoom: int,
    cdn_purge_fn: Callable[[list[str]], None],
    *,
    style_version: str = "v7",
    base_url: str = "https://tiles.example.com",
    ext: str = "pbf",
    batch_size: int = 400,
    origin_delete_fn: Callable[[list[str]], None] | None = None,
) -> dict:
    """
    Invalidate every tile touched by an edit inside `bbox`.

    1. Enumerate affected z/x/y tiles (buffered for symbology bleed).
    2. Build versioned URLs that match both origin and CDN cache keys.
    3. Purge the origin cache first, then the CDN edge, in batches sized
       to the purge API limit. Origin-first ordering guarantees that a
       post-purge edge miss repopulates from fresh origin tiles rather
       than re-caching a stale render.

    Returns a summary with the tile and batch counts for observability.
    """
    tiles = list(affected_tiles(bbox, minzoom, maxzoom))
    urls = [tile_url(t, style_version, base_url, ext) for t in tiles]

    batches = 0
    for batch in _chunked(urls, batch_size):
        if origin_delete_fn is not None:
            origin_delete_fn(batch)   # clear origin BEFORE the edge
        cdn_purge_fn(batch)           # then purge the edge
        batches += 1

    return {
        "bbox": bbox,
        "zoom_range": [minzoom, maxzoom],
        "tiles_purged": len(urls),
        "batches": batches,
        "style_version": style_version,
    }


# ── CDN adapter: Fastly soft-purge by URL ───────────────────────────────────

def make_fastly_purge_fn(api_token: str, timeout: float = 5.0):
    """Return a cdn_purge_fn that soft-purges a batch of URLs via Fastly."""
    session = requests.Session()
    session.headers.update({"Fastly-Key": api_token, "Fastly-Soft-Purge": "1"})

    def purge(urls: list[str]) -> None:
        for url in urls:
            resp = session.request("PURGE", url, timeout=timeout)
            resp.raise_for_status()

    return purge


# ── Origin adapter: Redis tile store ────────────────────────────────────────

def make_redis_delete_fn(redis_client):
    """Return an origin_delete_fn that deletes tile keys from Redis in a pipeline."""
    def delete(urls: list[str]) -> None:
        keys = [u.split("://", 1)[-1].split("/", 1)[-1] for u in urls]
        pipe = redis_client.pipeline()
        for k in keys:
            pipe.delete(k)
        pipe.execute()

    return delete


if __name__ == "__main__":
    # A building footprint edit in downtown Portland, OR.
    changed_bbox = (-122.6815, 45.5150, -122.6740, 45.5205)

    fastly_purge = make_fastly_purge_fn(api_token="REPLACE_WITH_SCOPED_TOKEN")

    summary = invalidate_bbox(
        changed_bbox,
        minzoom=12,
        maxzoom=18,          # deep zooms fill lazily; do not purge above this
        cdn_purge_fn=fastly_purge,
        style_version="v7",
        base_url="https://tiles.example.com",
        ext="pbf",
    )
    print(summary)
    # → {'bbox': (...), 'zoom_range': [12, 18], 'tiles_purged': 173, 'batches': 1, ...}

The design choice worth noting is dependency injection of the purge and delete functions. The invalidation math — enumerate, buffer, key, batch — is vendor-neutral; only the adapters know about Fastly headers or Redis pipelines. That separation lets you unit-test invalidate_bbox with a fake cdn_purge_fn that records calls, and swap CDNs without touching the tile logic. It also composes cleanly with a vector tile generation pipeline: the same bbox that triggered a re-tile job feeds straight into invalidate_bbox so freshly built tiles replace the purged ones.

Performance Optimization Patterns

1. Optimise for cache hit ratio, not raw purge speed. The metric that governs your render-tier load is the fraction of requests served from cache. Track it per zoom band. A healthy pyramid sees >99% hit ratio at low zoom (few tiles, heavy reuse) and progressively lower ratios at deep zoom. If a whole zoom band’s hit ratio collapses after a deploy, you have a key mismatch (Pitfall 3), not a traffic problem — the edge is treating every request as a miss because the URL changed.

2. Seed only the low zooms; let depth fill lazily. Seeding is O(4^z) per level, so seeding through z12 is ~22 million tiles worldwide while z18 is ~69 billion. Pre-render the shallow levels where every tile is near-certain to be hit, and let the rarely-requested deep tiles populate on demand. This keeps seed jobs bounded and the render tier’s speculative work proportional to actual demand.

3. Batch purges to the API limit and coalesce duplicates. Purge APIs charge per request and cap URLs per call (Fastly ~256 for batch, Cloudflare 30 files/call on many plans). The _chunked helper groups URLs into batches at that limit. Before batching, deduplicate: overlapping edits in the same job frequently touch the same low-zoom tile, and purging it five times is wasted quota. A set() of URLs before chunking turns O(edits × tiles) purge calls into O(unique_tiles / batch_size).

4. Coalesce concurrent misses to avoid the thundering herd. When a popular tile is purged, the next N simultaneous requests all miss and all trigger a render of the same tile — the thundering herd. Guard the origin with a per-key lock (a Redis SET key NX PX 30000 request-coalescing lock) so the first miss renders while the rest wait on the result. This bounds render-tier concurrency to the number of distinct purged tiles under load, not the request rate against them.

def render_with_coalescing(key: str, redis_client, render_fn, ttl_ms: int = 30000):
    """Only the first concurrent miss renders; others poll the shared result."""
    lock_key = f"lock:{key}"
    got_lock = redis_client.set(lock_key, "1", nx=True, px=ttl_ms)
    if got_lock:
        tile = render_fn(key)          # sole renderer for this key
        redis_client.set(key, tile, px=ttl_ms)
        redis_client.delete(lock_key)
        return tile
    # Another worker is rendering; wait briefly for it to publish.
    for _ in range(50):
        cached = redis_client.get(key)
        if cached is not None:
            return cached
    return render_fn(key)              # fallback if the leader stalled

Common Pitfalls and Debugging

1. Stale tiles after a data edit. Symptom: the map shows the old geometry minutes after the edit committed. Cause: the origin cache was cleared but the CDN edge still holds the tile, or the edit never triggered an invalidation event. Fix: purge both layers, origin first, and confirm the edit pipeline actually calls invalidate_bbox with the edited bbox. This edge-versus-origin diagnosis is involved enough that it has its own walkthrough in debugging stale tiles in CDN-backed map servers.

2. Over-purging the whole cache. Symptom: latency spikes and render-tier saturation after every small edit. Cause: a purge-by-tag or a wildcard purge that clears far more than the edit touched — or a maxzoom set to the pyramid’s floor, so one building edit purges thousands of deep tiles. Fix: scope purges to the affected bbox and cap maxzoom. If you need a hard reset, use a version bump (which is lazy — tiles re-render on demand) rather than a mass purge (which is eager and cold-starts the pyramid).

3. Mismatched cache keys between renderer and CDN. Symptom: purges return success but the tile never refreshes, or hit ratio for a zoom band drops to zero after a deploy. Cause: the renderer keys tiles as /{z}/{x}/{y} while the CDN cached /{version}/{z}/{x}/{y}, or a trailing query string (?access_token=…) is part of the edge key but omitted from the purge URL. Fix: share one tile_url function between the renderer, the seeder, and the purger. Log the exact string each service uses for a known tile and diff them.

4. Negative-caching 404 tiles for too long. Symptom: a newly seeded region keeps returning 404 for tiles that now exist. Cause: an earlier request cached the 404 with the same long TTL as real tiles, and the edge keeps serving it. Fix: give 404 responses a short TTL (minutes) via a distinct Cache-Control, and purge negative entries for a region as part of any seed job that covers it.

5. Wrong tile-scheme axis (XYZ vs TMS). Symptom: purged tiles are correct in x but flipped in y — the invalidation misses and a different band goes stale. Cause: mercantile emits XYZ (y=0 at the north edge) but the cache stores TMS (y=0 at the south edge). Fix: if the origin is TMS, convert with y_tms = (2 ** tile.z) - 1 - tile.y before building the key. Keep the convention in one place and assert it in a test with a known tile.

Conclusion

Tile cache invalidation is fundamentally an arithmetic problem wrapped in an operational one. The arithmetic is settled: a changed bbox expands, through mercantile.tiles, into a bounded and computable set of z/x/y coordinates, and the cost of that set grows fourfold per zoom level — which is why every real system caps maxzoom and leans on short TTLs above it. The operational discipline is keeping one cache-key definition shared across renderer, seeder, and CDN, purging origin before edge, and reaching for a version bump rather than a mass purge when a style changes rather than data. Get those right and the cache does what it exists to do: absorb almost all traffic at the edge while serving pixels that are never meaningfully stale. From here, the natural next steps are wiring this invalidation service into the tile build itself via vector tile generation and hardening the edge-versus-origin diagnosis path described in the child guide below.


FAQ

Why do stale tiles persist after I purge the origin cache? A tile that survives an origin purge is almost always still cached at the CDN edge or an intermediate proxy. Purging Redis or the filesystem cache only clears the origin; the edge holds its own copy until its TTL expires or it receives an explicit purge for the exact URL. Purge both layers, and make sure the URL sent to the CDN matches byte-for-byte what the edge cached, including the style version token and any query string.

How many z/x/y tiles does a single data edit actually touch? The count is bounded by the edited feature’s bounding box, not the whole dataset. At a given zoom z the number of tiles is roughly (span_x / tile_width) × (span_y / tile_height), and it quadruples per zoom level. A small building edit might touch one tile at z14 but hundreds at z19, which is why pipelines cap explicit invalidation at a maximum zoom and rely on short TTLs above it.

Should I negative-cache 404 responses for missing tiles? Cache 404s only with a short TTL, never the long TTL you give real tiles. Over-water and out-of-coverage tiles legitimately 404, and caching those avoids repeat render attempts. But if you seed that region later, a long-lived negative entry keeps returning 404 for tiles that now exist. Use a TTL of minutes, and purge negative entries as part of any seed job for the area.

Why does purging by bounding box sometimes miss tiles at the edges? A feature rendered near a tile boundary bleeds into the neighbouring tile because labels, halos, and line casings extend beyond the geometry’s own extent. Enumerating from the tight geometry bbox misses those neighbours. Buffer the bbox by a rendering margin before calling mercantile.tiles, or expand the resulting tile set by one tile in each direction so overlapping symbology is invalidated too.


Back to Server-Side Map Rendering and Tile Automation