Caching and CDN Strategies for Raster Tiles

Make the URL immutable, then cache it for a long time:

@app.middleware("http")
async def cache_headers(request, call_next):
    response = await call_next(request)
    if request.url.path.startswith("/cog/tiles"):
        response.headers["Cache-Control"] = "public, max-age=86400, s-maxage=604800, immutable"
    return response

Everything else follows from treating a tile URL as a name for content that will never change. This page belongs to serving raster tiles with TiTiler in Visualization, Tiling & Web Delivery.


Why Tiles Cache So Well

Two properties make raster tiles close to the ideal cached object. A tile is a pure function of its URL — same source, same parameters, same pixels — so a cached copy is never stale as long as the URL identifies a fixed product. And viewing is extremely concentrated: most users look at the same few areas, at the same few zooms.

Most requests hit a small set of tiles Ranked by popularity, the top one per cent of tiles receive roughly sixty per cent of all requests and the top ten per cent receive about ninety. The long tail of rarely viewed tiles accounts for most distinct URLs but a small share of traffic, which is why a modest cache captures most of the load. Cumulative share of requests by tile popularity 100% 0% top 1% → 60% top 10% → 90% tiles ranked from most to least requested A cache holding only the head of this curve absorbs almost all the traffic.

The consequence is that the cache does not need to be large to be effective. Holding the popular head of the distribution is enough to take most of the load off the tile server and the storage behind it, and the long tail — tiles requested once — costs little whether or not it is cached.


Environment & Setup

Component Version or setting Used for
titiler.application >=0.18 The origin whose responses are cached
CDN any Shared cache in front of the origin
httpx >=0.27 Warming and measuring from Python
Access logs enabled Computing the real hit rate
pip install "httpx>=0.27"

Complete Working Example

import asyncio

import httpx
import morecantile

TMS = morecantile.tms.get("WebMercatorQuad")


async def warm(template: str, bbox: tuple[float, float, float, float],
               zooms: range, *, concurrency: int = 16) -> dict[str, int]:
    """Request every tile over a bbox so the CDN holds them before users arrive."""
    sem = asyncio.Semaphore(concurrency)
    stats = {"ok": 0, "hit": 0, "miss": 0, "error": 0}

    async def fetch(client: httpx.AsyncClient, t) -> None:
        url = (template.replace("{z}", str(t.z))
                       .replace("{x}", str(t.x))
                       .replace("{y}", str(t.y)))
        async with sem:
            try:
                r = await client.get(url, timeout=30)
                r.raise_for_status()
            except httpx.HTTPError:
                stats["error"] += 1
                return
        stats["ok"] += 1
        # Most CDNs report hit/miss in a response header
        status = (r.headers.get("x-cache") or r.headers.get("cf-cache-status") or "").lower()
        stats["hit" if "hit" in status else "miss"] += 1

    tiles = [t for z in zooms for t in TMS.tiles(*bbox, [z])]
    async with httpx.AsyncClient(http2=True) as client:
        await asyncio.gather(*(fetch(client, t) for t in tiles))
    return stats


if __name__ == "__main__":
    template = ("https://tiles.example.org/cog/tiles/WebMercatorQuad/{z}/{x}/{y}.png"
                "?url=s3%3A%2F%2Fexample-bucket%2Fproducts%2Fv3%2Fndvi.tif&rescale=-0.2%2C0.9")
    print(asyncio.run(warm(template, (34.4, 0.1, 35.1, 0.6), range(8, 13))))

Warming is worth doing for the handful of areas you know will be viewed — the study area, the demo location, whatever a launch announcement links to. Warming the whole world is pointless: the long tail will never be requested, and pre-rendering it costs exactly what serving it on demand would have cost, without any of the tiles being used.


Invalidation by Versioning

Purge the cache, or change the name Overwriting a product in place leaves old tiles in the cache until they expire or are purged, so viewers see a mixture of old and new for hours. Publishing the reprocessed product under a new version path produces new URLs that have never been cached, so every viewer switches cleanly the moment the layer points at the new version. Reprocessing a published product overwrite in place same URL, different pixels cache serves old tiles until expiry viewers see a patchwork of versions purge is slow, partial, often billed new version path .../products/v4/ndvi.tif new URLs have never been cached switch is instant and clean v3 stays available for anyone pinned The cache never needs to be told anything; the URL does the work.

Versioned paths remove the invalidation problem rather than solving it. The cache never holds stale content because a URL never refers to different content, and a reprocessed product simply lives at a new address. The same principle governs the catalog side, as described in attaching model metadata to STAC items.

The one thing that must change on a new version is whatever tells viewers which version to load — a layer configuration, a catalog link, a “latest” alias. That pointer should have a short cache lifetime of its own, measured in minutes, so the switch propagates quickly while the tiles themselves stay cached for weeks.


Verification

import collections
import re

def hit_rate_from_log(path: str) -> float:
    """Compute the tile hit rate from a CDN access log with a cache-status field."""
    counts = collections.Counter()
    pattern = re.compile(r"/tiles/.*\s(HIT|MISS|EXPIRED|REVALIDATED)\b")
    with open(path, encoding="utf-8") as fh:
        for line in fh:
            m = pattern.search(line)
            if m:
                counts[m.group(1)] += 1
    total = sum(counts.values())
    return counts["HIT"] / total if total else float("nan")
One tile, three cache keys Three requests for the same tile differ only in parameter order and in how a float is written. The CDN treats each as a distinct object, so the tile is rendered three times and cached three times. Normalising the URL on the client collapses them to one key and one render. Cosmetic URL differences defeat the cache ...?rescale=-0.2,0.9&colormap_name=rdylgn ...?colormap_name=rdylgn&rescale=-0.2,0.9 ...?rescale=-0.20000001,0.9&colormap_name=rdylgn 3 renders, 3 cache entries for one visible tile Build every template from one function and the three become one.

A hit rate that stays low despite long lifetimes almost always means something in the URL varies between requests — a timestamp, a random cache-buster, parameters in a different order, or rescale values computed per session with slightly different floats. Normalise the URL on the client so the same tile always has the same address.

It is worth checking the origin’s own timing alongside the hit rate. A high hit rate with a slow origin is fine for users but means the first viewer of each tile waits; a low hit rate with a fast origin is fine for users but costs more. Both numbers together tell you which lever to pull.


Common Errors

The CDN never caches tiles

The origin sends Cache-Control: private or no-store, often inherited from framework defaults. Set explicit public headers on tile routes only.

Viewers see old tiles after reprocessing

The product was overwritten in place. Publish under a new version path and move the layer pointer.

The hit rate is poor despite long lifetimes

Query parameters vary between requests. Build templates in one place and keep parameter order and float formatting stable.

Error responses are being cached

A transient 500 was served with a long lifetime and is now pinned. Set cache headers only on successful responses, and give errors no-store.


Frequently Asked Questions

Q: How long should tiles be cached? As long as the URL can never refer to different content — which, if the product version is in the URL, is indefinitely. A week in the shared cache and a day in browsers is a safe starting point that survives the occasional mistake.

Q: How do I invalidate cached tiles after reprocessing? Do not invalidate; publish under a new URL. Purging a CDN is slow, partial and often billed, whereas a new version path is instant and leaves the old one available for anyone who pinned it.

Q: What hit rate should a published layer reach? Ninety per cent or better within a day or two of publication for a layer with a real audience, because viewing concentrates heavily on a few areas. A hit rate stuck below seventy per cent usually means something in the URL varies per request.

Q: Does caching change the cost of the tile service much? Dramatically. At a ninety-five per cent hit rate the origin handles one request in twenty, and both compute and storage egress fall proportionally. For most published layers the CDN bill is smaller than the origin savings it produces.