Invalidating Tile Caches After a Source Update
When a source scene is republished, the tiles that need rebuilding are not just the ones its footprint covers — they are those tiles buffered outward by the resampling kernel’s reach, plus every parent tile up the pyramid that was built from them. Compute that set from the changed extent rather than purging by prefix, and a quarterly imagery update becomes a few thousand targeted rebuilds instead of a full continental re-render. The buffer is the part that gets forgotten, and forgetting it leaves a visible seam along every edge of the updated area.
When to Use This Pattern
- A tile pyramid is built from sources that change independently — one scene at a time, one municipality at a time.
- Rebuilding everything is not an option, because the pyramid has millions of tiles and the sources change weekly.
- A CDN sits in front of the tiles, so invalidation has to reach the edge as well as the origin.
- Seams have appeared along the boundary of a previously updated area — the signature of invalidating without a buffer.
Complete Working Example
The invalidator takes a changed extent in any CRS, converts it to the tile grid’s CRS, buffers it, and enumerates the affected tiles at every zoom level in the pyramid.
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Iterator
from pyproj import Transformer
from shapely.geometry import box
from shapely.ops import transform as shapely_transform
# Web Mercator extent, the grid nearly every tile pyramid uses.
WORLD = 20037508.342789244
TILE_PX = 256
# How far a resampling kernel reaches beyond the pixel it is producing, in
# source pixels. Bilinear touches 2x2, cubic 4x4, lanczos 6x6 — so a tile whose
# edge sits one pixel outside the changed area still changes.
KERNEL_REACH = {"nearest": 0, "bilinear": 1, "cubic": 2, "cubicspline": 2, "lanczos": 3}
@dataclass(frozen=True)
class Tile:
z: int
x: int
y: int
@property
def key(self) -> str:
return f"tiles/{self.z}/{self.x}/{self.y}.png"
def resolution_at(z: int) -> float:
"""Metres per pixel at this zoom, in Web Mercator."""
return (2 * WORLD) / (TILE_PX * (2 ** z))
def tiles_for_extent(minx: float, miny: float, maxx: float, maxy: float, z: int) -> Iterator[Tile]:
"""Every tile at zoom z whose footprint intersects the extent (EPSG:3857)."""
span = (2 * WORLD) / (2 ** z)
x0 = max(0, int((minx + WORLD) // span))
x1 = min(2 ** z - 1, int((maxx + WORLD) // span))
# Tile y counts downward from the top of the world in the XYZ scheme.
y0 = max(0, int((WORLD - maxy) // span))
y1 = min(2 ** z - 1, int((WORLD - miny) // span))
for x in range(x0, x1 + 1):
for y in range(y0, y1 + 1):
yield Tile(z, x, y)
def affected_tiles(
changed_wkt: str, source_crs: str, min_zoom: int = 0, max_zoom: int = 16,
resampling: str = "bilinear",
) -> set[Tile]:
"""Every tile that must be rebuilt after `changed_wkt` was republished."""
from shapely import from_wkt
geom = from_wkt(changed_wkt)
if source_crs != "EPSG:3857":
transformer = Transformer.from_crs(source_crs, "EPSG:3857", always_xy=True)
geom = shapely_transform(transformer.transform, geom)
reach = KERNEL_REACH.get(resampling, 1)
out: set[Tile] = set()
for z in range(min_zoom, max_zoom + 1):
# Buffer in METRES at this zoom: the kernel's reach is in pixels, and a
# pixel is a different size at every level. Forgetting this leaves seams
# at low zooms while looking correct at high ones.
buffered = geom.buffer(reach * resolution_at(z))
out.update(tiles_for_extent(*buffered.bounds, z=z))
return out
def invalidate(s3, bucket: str, tiles: set[Tile], cdn=None, distribution_id: str = "") -> int:
"""Delete from origin, then ask the CDN to forget. Order matters."""
keys = [{"Key": t.key} for t in sorted(tiles, key=lambda t: (t.z, t.x, t.y))]
for batch in (keys[i:i + 1000] for i in range(0, len(keys), 1000)):
s3.delete_objects(Bucket=bucket, Delete={"Objects": batch, "Quiet": True})
if cdn is not None and keys:
# Path-based invalidation is metered and slow; a small number of wildcard
# paths beats ten thousand exact ones when the set is contiguous.
paths = _collapse_to_wildcards([k["Key"] for k in keys])
cdn.create_invalidation(
DistributionId=distribution_id,
InvalidationBatch={
"Paths": {"Quantity": len(paths), "Items": paths},
"CallerReference": f"tiles-{hash(frozenset(tiles)) & 0xffffffff}",
},
)
return len(keys)
The buffer’s size deserves one more note, because there are really two reaches stacked on top of each other. The resampling kernel reaches a couple of source pixels beyond each output pixel, which is what the table above encodes. But if the rendering step also draws labels, halos or line casings, those reach much further — a road casing 8 pixels wide means a feature 8 pixels outside the tile still paints into it. Where the pyramid is rendered rather than resampled, take the buffer from the widest symbol in the style, not from the kernel, or the seams will appear along roads rather than along tile edges and take much longer to attribute.
Parameter & Option Reference
| Parameter | Type | Default | Spatial notes |
|---|---|---|---|
KERNEL_REACH |
dict | 0–3 px | In source pixels. Bilinear reaches one, cubic two, lanczos three. Nearest genuinely needs no buffer. |
| buffer units | metres | per zoom | The reach is in pixels and a pixel’s size changes per zoom, so the buffer must be recomputed at each level. |
min_zoom / max_zoom |
int |
0 / 16 |
Low zooms are cheap and few; skipping them is a false economy that leaves stale overviews. |
| batch size | int |
1000 |
The delete_objects maximum. Larger batches are rejected outright. |
| CDN paths | wildcards | collapsed | Path invalidations are metered. Collapsing a contiguous block to /tiles/12/2100/* is far cheaper. |
| grid CRS | EPSG:3857 |
— | Convert the changed extent into the grid’s CRS before anything else; the buffer is meaningless in the wrong units. |
Verification & Testing
The tests worth writing are about the boundary: that the buffer widens the set, and that tile-y is not flipped.
def test_buffer_adds_a_ring() -> None:
footprint = box(1_000_000, 7_000_000, 1_010_000, 7_010_000).wkt
plain = affected_tiles(footprint, "EPSG:3857", 12, 12, resampling="nearest")
buffered = affected_tiles(footprint, "EPSG:3857", 12, 12, resampling="lanczos")
assert buffered > plain, "lanczos must invalidate more than nearest"
def test_tile_y_is_not_flipped() -> None:
"""A northern extent must map to a LOW y in the XYZ scheme."""
north = box(0, 8_000_000, 10_000, 8_010_000).wkt
south = box(0, -8_010_000, 10_000, -8_000_000).wkt
ny = min(t.y for t in affected_tiles(north, "EPSG:3857", 8, 8))
sy = min(t.y for t in affected_tiles(south, "EPSG:3857", 8, 8))
assert ny < sy, "y axis inverted — northern tiles must have smaller y"
def test_every_zoom_is_covered() -> None:
footprint = box(1_000_000, 7_000_000, 1_001_000, 7_001_000).wkt
tiles = affected_tiles(footprint, "EPSG:3857", 0, 14)
assert {t.z for t in tiles} == set(range(15))
The y-axis test earns its place: the XYZ scheme counts y downward from the north pole while TMS counts upward, and a pipeline that mixes the two invalidates a mirror image of the correct area. The symptom is stale tiles in one hemisphere and pointless rebuilds in the other, which is confusing enough to lose an afternoon over.
Operationally, log the count per zoom before purging anything. An invalidation that touches every tile at zoom 3 means the extent was mis-transformed and now covers a continent:
by_zoom = Counter(t.z for t in tiles)
logger.info("invalidating %d tiles: %s", len(tiles), dict(sorted(by_zoom.items())))
if by_zoom[3] > 16:
raise ValueError("low-zoom count implausible — check the source CRS")
Common Pitfalls
- No buffer at all. The most common version of this bug. Tiles at the edge of the changed area are built from source pixels partly inside and partly outside it, so they change even though their footprint is not fully covered. The result is a visible seam that survives until an unrelated update happens to catch it.
- Buffering in pixels instead of metres. The kernel’s reach is expressed in pixels, and a pixel is 150 metres at zoom 10 and 0.6 metres at zoom 18. A fixed metre buffer over-invalidates at high zoom and under-invalidates at low zoom.
- Flipping the y axis. XYZ counts y from the north, TMS from the south. Mixing them invalidates the mirror image of the intended area, which looks superficially plausible on a world map.
- Purging by prefix.
aws s3 rm --recursive tiles/12/is fast to type and deletes a whole zoom level. On a large pyramid the rebuild costs hours, and the CDN then misses on everything for the rest of the day. - Forgetting the CDN. Deleting from the origin does nothing to the edge caches, which will happily serve the old tile until its own TTL expires. The invalidation has to reach both, in that order.
Frequently Asked Questions
How do I get the changed extent in the first place?
From whatever published the update. A STAC item has a geometry; a delivery manifest usually has a bounding box; a database trigger can capture the union of changed rows. Where nothing is available, compare the new source’s footprint against the previous one and take the union — that over-invalidates slightly and is still far better than a full purge.
Should parent tiles be rebuilt or just deleted?
Deleted, if your serving layer builds on demand; rebuilt eagerly if it does not. Eager rebuilds cost compute you may not need, since nobody may look at zoom 4 today. Lazy rebuilds cost the first viewer a slow tile. Most pyramids do best deleting everything and rebuilding the top four or five levels eagerly, because those are the ones every user hits.
What about tiles that are pure nodata?
They still need invalidating if the change touched them, because “nodata” is a rendering decision that a new source can change. What they do not need is storage: many pyramids write a sentinel or nothing at all for empty tiles, in which case invalidation is a no-op for those keys and the cost disappears naturally.
How does this interact with a breaker's cached-tile fallback?
Directly. Tiles served from cache during an outage carry tile-source: cache metadata, and they are stale by construction. Feed those coordinates into this same invalidation path once the endpoint recovers — see falling back to cached tiles when a breaker opens. An outage is a source update as far as the cache is concerned.
Related
- Caching strategies for spatial tasks — the keys these tiles are stored under
- Caching reprojected rasters with content hashing — the layer beneath the tiles
- Skipping tiles with no new source data — the same extent arithmetic used to avoid work
- Visualizing tile coverage gaps on a geomap — seeing what an invalidation left behind