Pre-Seeding Tile Caches for Hot Viewports

Warm the tiles users actually request instead of the whole pyramid: derive hot areas from access logs, seed by zoom band, and stop after the point where hit rate stops improving.

← 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 stats

Cache-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.

Hit rate against tiles seededCache hit rate plotted against the number of tiles pre-seeded, on a logarithmic horizontal axis. Seeding 100 tiles reaches 31 percent, 500 reaches 62 percent, 2000 reaches 84 percent, 4000 reaches 91 percent and 20000 reaches 94 percent. Beyond 4000 the curve is essentially flat, so the last 16000 tiles buy three percentage points at four times the generation cost. A marker at 4000 identifies the practical stopping point.Hit rate versus seeding effort, one regional dataset100 %50 %04 000 tiles → 91 %stop here+16 000 tiles buys +3 pointsat 4× the generation cost1001 00010 000100 000Map traffic is heavily concentrated, so the curve always has this shape — only the position of the knee changes.

Key Parameters & Options

ParameterSuggestedNotes
top_nderived from the hit-rate curveMeasure once, then keep the number
Seeding orderascending zoomLow zooms are few, costly and always requested
CONCURRENCY4–8Seeding must not itself become the load spike
Cache-Control: no-cacheon the seeding requestForces revalidation so the edge stores the new tile
Ancestor expansiononA hot tile implies its parents were on screen
Triggerafter data refreshNot 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

Refresh, seed, peak — and what happens without the middle stepA timeline from 02:00 to 09:00. The data import finishes at 02:30 and tiles are invalidated. With seeding, a job runs from 02:40 to 03:20 warming 4000 tiles, and the 08:00 traffic peak is served at 91 percent hit rate with origin latency around 12 milliseconds. Without seeding, the same peak arrives at a cold cache, hit rate starts near zero and origin latency reaches 340 milliseconds for the first twenty minutes while the cache fills from live traffic.The window between invalidation and the morning peak02:30 import donetiles invalidatedseed 4 000 tiles · 40 min08:00 peak02:0005:0009:00with seeding91 % hit · origin p95 12 ms at peakwithout seedingcold start · origin p95 340 ms for the first 20 minutesThe peak is unavoidable; paying for it at 03:00 with eight concurrent requests instead of at 08:00 withtwo thousand is the entire idea.

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.

Cumulative request share by tile rankCumulative share of all tile requests plotted against tiles ranked by popularity. The top 100 tiles absorb 31 percent of requests, the top 1000 absorb 74 percent, the top 4000 absorb 91 percent and the top 20000 absorb 94 percent. Beyond that the curve is flat: the remaining 2.4 million candidate tiles in the region account for the final 6 percent, and roughly 2.1 million of them were never requested at all during the observed month.One month of tile requests, ranked100 %0top 4 000 tiles = 91 %this is the whole seeding set2.1 M tiles in this region werenever requested even once1001 00020 0002.4 MSeeding by traffic is not an approximation of seeding everything — it is the same result for a thousandthof the work, because almost nothing else is ever asked for.

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.1

← Back to Tile Generation & CDN Distribution