Scoping Tile Invalidation to the Zoom Levels That Changed
Derive the shallowest zoom from the change’s ground distance and the layer’s simplification tolerance, then invalidate only from that zoom down — a one-metre vertex nudge cannot alter a pixel until the pixel is smaller than a metre, so invalidating zoom 0 through 20 rebuilds thousands of tiles that will render byte-identical.
This guide sits under Tile Update Event Pipelines, within Core Event Fundamentals & Architecture. It assumes the change event carries both the previous and the current geometry, as produced by Capturing PostGIS Changes with Logical Replication.
When to use this pattern
- Tile rebuilds are a measurable cost, either in compute or in the cache invalidation waves they send to clients.
- Most edits are small: attribute changes, vertex nudges, minor reshaping. If every edit moves a feature a kilometre, the scoping saves nothing.
- The tile pipeline simplifies per zoom, which is where most of the saving comes from.
The arithmetic of a pixel
Complete runnable implementation
import math
import mercantile
from shapely.geometry import shape
from shapely.ops import transform
EARTH_CIRCUMFERENCE_M = 40_075_016.686
TILE_PIXELS = 512 # 256 for classic raster tiles
MAX_ZOOM = 20
# Per-zoom simplification tolerance in metres, as configured in the tile
# pipeline. Usually the dominant term: a change below the tolerance is
# discarded during tile generation, so the tile is byte-identical.
SIMPLIFY_TOLERANCE_M = {
z: max(0.5, 2 ** (16 - z)) for z in range(0, MAX_ZOOM + 1)
}
def metres_per_pixel(zoom: int, latitude: float) -> float:
"""Web Mercator ground resolution, corrected for latitude.
The cosine term matters: the same edit becomes visible a zoom level
shallower at 60 degrees north than it does at the equator.
"""
return (EARTH_CIRCUMFERENCE_M * math.cos(math.radians(latitude))
/ (TILE_PIXELS * 2 ** zoom))
def change_magnitude_m(before: dict, after: dict, latitude: float) -> float:
"""Largest ground distance any part of the geometry moved.
Hausdorff distance, not centroid distance: a symmetric reshaping moves
the boundary metres while leaving the centroid exactly where it was.
"""
a, b = shape(before), shape(after)
degrees = a.hausdorff_distance(b)
return degrees * (EARTH_CIRCUMFERENCE_M / 360.0) * math.cos(math.radians(latitude))
def min_visible_zoom(before: dict, after: dict, latitude: float,
layer_min_zoom: int = 0) -> int | None:
"""Shallowest zoom at which this change can alter a rendered tile.
Returns None when the change is invisible everywhere — which happens more
often than people expect, and is the case worth short-circuiting.
"""
magnitude = change_magnitude_m(before, after, latitude)
if magnitude == 0.0:
return None
for zoom in range(layer_min_zoom, MAX_ZOOM + 1):
# Both conditions must hold: bigger than a pixel AND surviving
# simplification. The second usually binds first.
if (magnitude >= metres_per_pixel(zoom, latitude)
and magnitude >= SIMPLIFY_TOLERANCE_M[zoom]):
return zoom
return None
def tiles_to_invalidate(event: dict, layer_min_zoom: int = 0):
"""Yield every tile that could render differently after this change."""
action = event["action"]
before, after = event.get("previous_geometry"), event.get("geometry")
# Presence is visible at every zoom, so an insert or a delete is always
# a full-depth invalidation regardless of how small the geometry is.
if action in ("insert", "delete") or before is None or after is None:
start = layer_min_zoom
else:
start = min_visible_zoom(before, after, _latitude(after), layer_min_zoom)
if start is None:
return
for geometry in (g for g in (before, after) if g):
west, south, east, north = shape(geometry).bounds
for zoom in range(start, MAX_ZOOM + 1):
yield from mercantile.tiles(west, south, east, north, zoom)
def _latitude(geometry: dict) -> float:
_, south, _, north = shape(geometry).bounds
return (south + north) / 2.0
Iterating over the two geometries separately rather than their union is deliberate: a feature that moved five kilometres has a combined envelope covering everything in between, most of which is untouched.
Parameter reference
| Name | Type | Spatial constraint | Default |
|---|---|---|---|
TILE_PIXELS |
int |
512 for vector tiles, 256 for classic raster; wrong value shifts every zoom by one | 512 |
SIMPLIFY_TOLERANCE_M |
dict[int, float] |
Must mirror the tile generator’s actual configuration | per zoom |
MAX_ZOOM |
int |
The deepest zoom the pipeline generates, not the deepest the client requests | 20 |
layer_min_zoom |
int |
Layers hidden above a zoom need no invalidation there | 0 |
| Distance measure | — | Hausdorff, not centroid — a symmetric reshape leaves the centroid still | — |
| Insert / delete | — | Always full depth; presence is visible at every zoom | — |
Gotchas and spatial edge cases
-
Centroid distance reports zero for a real change. A polygon reshaped symmetrically — two opposite edges pushed out by ten metres each — has the same centroid and a very different outline. Hausdorff distance measures the largest displacement of any boundary point, which is what a renderer draws.
-
SIMPLIFY_TOLERANCE_Mhas to mirror the tile generator, not approximate it. If the generator simplifies at five metres and this table says two, the scoping skips zooms that genuinely changed, and the resulting stale tile is indistinguishable from a caching bug. Read both from the same configuration source. -
An attribute-only change still needs invalidation where the attribute is rendered. Changing a road’s classification moves no vertex, so
change_magnitude_mreturns zero and the function returnsNone— but the tile styles that road differently. Handle attribute changes on their own path, scoped by which zooms render the changed attribute. -
Latitude matters more than people expect. At 60° north a Web Mercator pixel covers half the ground it does at the equator, so a change becomes visible one full zoom level shallower. Omitting the cosine term under-invalidates in exactly the northern cities where most editing happens.
-
A feature that moved far should not invalidate the corridor between. Bounding the union of before and after covers every tile in between; iterate over the two geometries separately, as the implementation does, or a vehicle depot relocation invalidates a county.
-
Labels and halos extend beyond the geometry’s bounds. A renamed feature can change pixels in the neighbouring tile because its label overflows the tile edge. If the layer draws labels, buffer the invalidation area by the maximum label extent, which is a style property rather than a geometric one.
Verification
import pytest
from shapely.geometry import Polygon, mapping
BLOCK = Polygon([(13.4000, 52.5200), (13.4000, 52.5210),
(13.4015, 52.5210), (13.4015, 52.5200)])
def nudged(metres: float) -> dict:
"""Move one vertex by roughly `metres` at this latitude."""
delta = metres / 111_320.0
coords = list(BLOCK.exterior.coords)
coords[2] = (coords[2][0] + delta, coords[2][1])
return mapping(Polygon(coords))
def test_one_metre_nudge_is_invisible_until_deep_zoom():
z = min_visible_zoom(mapping(BLOCK), nudged(1.0), latitude=52.52)
assert z is not None and z >= 17
def test_forty_metre_change_is_visible_much_shallower():
z = min_visible_zoom(mapping(BLOCK), nudged(40.0), latitude=52.52)
assert z is not None and z <= 13
def test_identical_geometry_invalidates_nothing():
assert min_visible_zoom(mapping(BLOCK), mapping(BLOCK), latitude=52.52) is None
def test_symmetric_reshape_is_not_missed():
"""Centroid distance would report zero here."""
coords = list(BLOCK.exterior.coords)
d = 10.0 / 111_320.0
coords[0] = (coords[0][0] - d, coords[0][1])
coords[2] = (coords[2][0] + d, coords[2][1])
reshaped = mapping(Polygon(coords))
assert min_visible_zoom(mapping(BLOCK), reshaped, latitude=52.52) is not None
def test_insert_invalidates_every_zoom():
event = {"action": "insert", "geometry": mapping(BLOCK), "previous_geometry": None}
zooms = {t.z for t in tiles_to_invalidate(event)}
assert min(zooms) == 0
The symmetric-reshape test is the one that fails if someone replaces the Hausdorff call with a centroid comparison for speed — a change that looks harmless, passes the other four tests, and silently stops invalidating any edit that preserves a feature’s centre.
Related
- Tile Update Event Pipelines — the topic this guide belongs to
- Debouncing Rapid Feature Edits — collapsing the burst before any of this arithmetic runs
- An Error-Budget Policy for Tile Pipelines — deciding what to do when the rebuild queue cannot keep up anyway
- Capturing PostGIS Changes with Logical Replication — where the previous geometry this comparison needs comes from