← Back to Tile Generation & CDN Distribution
This page covers warming a tile cache from evidence: deriving the hot set from real traffic, seeding in the order that pays first, and knowing when to stop.
Context & When to Use
After a data refresh every cached tile is stale, and the next few hundred users pay origin latency to regenerate them. On a dataset where tile generation is 23 ms that is tolerable; where it is 200 ms, or where a burst of traffic arrives at 08:00 with a cold cache, it is a visible outage of responsiveness. Pre-seeding moves that work into a window where nobody is watching.
The naive version — walk the whole pyramid — does not survive contact with the numbers. Zoom 14 has 268 million tiles worldwide; even bounded to one country it is millions, and the great majority are ocean, farmland or car parks that nobody has ever requested. Generating them costs hours and storage, and improves the hit rate by almost nothing.
The traffic-derived version inverts it. Most map traffic concentrates hard: a handful of cities, a few zoom levels, and within those the tiles containing whatever the product is about. Seeding the top few thousand tiles typically reaches the same hit rate as seeding millions. The delivery mechanics this builds on are in Tile Generation & CDN Distribution, and the header policy that keeps seeded tiles alive is in Caching Vector Tiles at the Edge with Cache-Control.
Runnable Implementation
import asyncio
import re
from collections import Counter
from typing import Iterable, Iterator
import httpx
TILE_RE = re.compile(r"/v1/tiles/(\d+)/(\d+)/(\d+)\.mvt")
BASE = "https://api.example.com"
CONCURRENCY = 8 # polite: seeding must not become the load spike
def hot_tiles(log_lines: Iterable[str], top_n: int = 4_000) -> list[tuple[int, int, int]]:
"""Rank tiles by real request count over the observed window."""
counts: Counter[tuple[int, int, int]] = Counter()
for line in log_lines:
m = TILE_RE.search(line)
if m:
counts[(int(m[1]), int(m[2]), int(m[3]))] += 1
# Low zooms first: fewer tiles, more expensive, requested by everyone
return sorted(counts, key=lambda t: (t[0], -counts[t]))[:top_n]
def with_parents(tiles: Iterable[tuple[int, int, int]]) -> Iterator[tuple[int, int, int]]:
"""Every hot tile implies its ancestors were on screen during the zoom in."""
seen: set[tuple[int, int, int]] = set()
for z, x, y in tiles:
while z >= 0:
if (z, x, y) not in seen:
seen.add((z, x, y))
yield (z, x, y)
z, x, y = z - 1, x // 2, y // 2
async def seed(tiles: list[tuple[int, int, int]]) -> dict[str, int]:
"""Request each tile through the CDN so edge and origin both warm."""
stats = {"ok": 0, "empty": 0, "failed": 0}
limiter = asyncio.Semaphore(CONCURRENCY)
async with httpx.AsyncClient(timeout=30) as client:
async def one(z: int, x: int, y: int) -> None:
async with limiter:
try:
r = await client.get(f"{BASE}/v1/tiles/{z}/{x}/{y}.mvt",
headers={"Cache-Control": "no-cache"})
except httpx.HTTPError:
stats["failed"] += 1
return
if r.status_code == 204:
stats["empty"] += 1
elif r.is_success:
stats["ok"] += 1
else:
stats["failed"] += 1
await asyncio.gather(*(one(*t) for t in tiles))
return statsCache-Control: no-cache on the seeding request is deliberate: it forces the edge to revalidate against the origin and store the fresh tile, which is exactly what “warming” means after an invalidation.
Key Parameters & Options
| Parameter | Suggested | Notes |
|---|---|---|
top_n | derived from the hit-rate curve | Measure once, then keep the number |
| Seeding order | ascending zoom | Low zooms are few, costly and always requested |
CONCURRENCY | 4–8 | Seeding must not itself become the load spike |
Cache-Control: no-cache | on the seeding request | Forces revalidation so the edge stores the new tile |
| Ancestor expansion | on | A hot tile implies its parents were on screen |
| Trigger | after data refresh | Not on a clock unrelated to invalidation |
The ancestor expansion is a small trick with a real payoff. Users arrive at zoom 14 by zooming in from zoom 8, so every hot deep tile implies a chain of shallower ones was fetched on the way. Seeding those costs almost nothing — there are very few of them — and they are the slowest to generate.
Where seeding fits in the refresh cycle
How concentrated tile traffic really is
The reason a few thousand tiles suffice is worth seeing rather than asserting. Map traffic follows a steep power law: a small number of tiles absorb most requests, and the tail is not merely long but almost entirely unvisited.
Gotchas & Failure Modes
- Seeding harder than production traffic. A seeder with 200 concurrent workers is a load test against your own origin. Keep concurrency low; the job has all night.
- Seeding tiles that are empty. A 204 response costs a database round trip and caches nothing useful. Count them, and prune persistently-empty tiles from the hot list.
- Hot list derived from the wrong window. A weekend’s logs seed the wrong cities for a Monday morning. Use a window that matches the traffic you are warming for.
- Seeding before the invalidation completes. Warming while the purge is still propagating fills the cache with tiles that are about to be evicted. Wait for the purge to confirm.
- No cap on the job’s duration. A hot list that has grown unnoticed can leave seeding still running at 08:00, competing with the traffic it was meant to help. Set a wall-clock budget and log what was skipped.
- Assuming the hit rate is uniform. The aggregate can look excellent while one important city is cold. Report hit rate per zoom band and per region, in line with Observability for Spatial Endpoints.
Finally, keep the seeding job’s own metrics separate from production traffic metrics. A seeding run that requests four thousand tiles will otherwise appear in the dashboards as a traffic spike with a suspiciously perfect cache-miss rate, and someone will eventually spend an afternoon investigating it.
Finally, keep the seeding job’s own metrics separate from production traffic metrics. A seeding run that requests four thousand tiles will otherwise appear in the dashboards as a traffic spike with a suspiciously perfect cache-miss rate, and someone will eventually spend an afternoon investigating it.
And record which tiles were seeded, not merely how many. When the hit rate disappoints, the useful question is whether the hot list was wrong or the seeding failed, and only a per-tile record distinguishes the two.
Verification Snippet
# Did the seeding run actually populate the edge?
for t in 8/127/84 10/511/340 14/8188/5448; do
curl -s -o /dev/null -D - "https://api.example.com/v1/tiles/$t.mvt" \
| grep -iE '^(cf-cache-status|age|x-cache):'
done
# cf-cache-status: HIT
# age: 3122-- Hit rate by zoom, from the edge logs loaded into a table
SELECT z,
count(*) AS requests,
round(100.0 * count(*) FILTER (WHERE cache_status = 'HIT')
/ count(*), 1) AS hit_pct
FROM tile_access_log
WHERE requested_at >= now() - interval '1 day'
GROUP BY z ORDER BY z;
-- z | requests | hit_pct
-- ----+----------+---------
-- 14 | 482013 | 93.1Related
- Tile Generation & CDN Distribution — the distribution layer being warmed
- Vector Tile Endpoints with ST_AsMVT — what each seeded request costs the origin
- Caching Vector Tiles at the Edge with Cache-Control — the header policy that decides how long a seeded tile survives
← Back to Tile Generation & CDN Distribution