← Back to Deploying & Operating Geospatial APIs
A vector-tile endpoint that works flawlessly for one developer on localhost becomes a database-melting liability the moment a map goes viral: a single MapLibre client panning across a city fires dozens of /tiles/{z}/{x}/{y}.mvt requests per second, and every uncached one runs a fresh ST_AsMVT aggregation on PostGIS. The fix is not a bigger database — it is an edge tier that answers the overwhelming majority of tile requests before they ever reach your FastAPI origin. This guide covers the tile contract that makes edge caching correct, how to build cache keys that invalidate cleanly, the three architectures for putting a CDN or edge runtime in front of the origin, and how to authorize tile access without destroying the cache hit ratio.
The tile-generation mechanics — the ST_AsMVT and ST_AsMVTGeom query, the Web Mercator envelope math, and the Redis hot-tile layer — are covered in depth in Tile Generation & CDN Distribution. This page assumes that origin exists and focuses on the operational layer above it: routing, keying, versioning, and offload. For where this fits in the wider operational stack, see the Deploying & Operating Geospatial APIs overview.
The tile contract and why it constrains everything
Everything downstream — the cache key, the invalidation strategy, the signing scheme — is determined by the shape of the URL. A production tile contract carries five pieces of identity in the path, not the query string, because most CDNs key on the path by default and ignore query parameters unless explicitly configured:
/tiles/{version}/{layer}/{z}/{x}/{y}.mvt
│ │ │ │ │
│ │ │ │ └── tile row (0 <= y < 2**z)
│ │ │ └────── tile col (0 <= x < 2**z)
│ │ └────────── zoom (0 <= z <= max_zoom)
│ └────────────────── layer / source name
└─────────────────────────── monotonic data version token
The .mvt extension (equivalently .pbf in some stacks) makes the MIME type unambiguous at the edge, which matters because a CDN must be told to cache application/vnd.mapbox-vector-tile — several providers do not cache unknown binary types by default. Putting version and layer in the path rather than the query string is the single most important decision on this page: it makes the CDN cache key correct with zero extra configuration, and it turns invalidation into a path change rather than a purge storm.
Prerequisites & Environment
This layer sits on top of a working async FastAPI + PostGIS tile origin. Confirm these baselines before adding an edge tier:
| Component | Minimum version | Why it matters |
|---|---|---|
| PostgreSQL | 14+ | Parallel aggregation for ST_AsMVT under concurrent tile load |
| PostGIS | 3.2+ | Stable ST_AsMVT / ST_AsMVTGeom memory handling |
| FastAPI | 0.100+ | Lifespan-managed asyncpg pool; Response with raw bytes |
| asyncpg | 0.29+ | Non-blocking binary protocol for the MVT bytea return |
| CDN / edge | Cloudflare, CloudFront, or Fastly | Must cache the vector-tile MIME type and honour s-maxage |
| A GiST index | — | The tile bbox filter is a sequential scan without it |
Install the origin dependencies:
pip install "fastapi>=0.100" "uvicorn>=0.27" "asyncpg>=0.29"Your geometry column needs a GiST spatial index or every cache miss becomes a full table scan. See Query Plan Analysis & Index Tuning for confirming the index engages with EXPLAIN ANALYZE, and Connection Pooling & PgBouncer Setup for keeping the origin pool from exhausting under the miss traffic that survives the edge.
Architecture: edge tier in front of the origin
The map client never talks to PostGIS. It talks to the edge, which either answers from cache or forwards a miss to the FastAPI origin, which runs ST_AsMVT and returns bytes the edge stores under the versioned key.
Decision matrix: where the tile is served from
Three architectures put a caching tier between the client and PostGIS. They differ in who runs the cache-miss logic and where the bytes are generated. Pick before you write config.
| Approach | Miss logic runs at | Origin load | Invalidation | First-byte on miss | Best for |
|---|---|---|---|---|---|
| Origin-pull CDN | Nowhere (CDN just forwards) | Every miss hits FastAPI | Version segment + Cache-Tag purge | 20–200 ms (ST_AsMVT) | Simplest; most workloads start here |
| Edge compute (Workers / Lambda@Edge) | The edge runtime | Only cold-region misses hit FastAPI | Version segment + KV flag | 20–200 ms cold, ~1 ms warm | Coordinate validation, auth, custom keys at the edge |
| Pre-rendered tile store (R2 / S3 + CDN) | A batch job, ahead of time | Zero at request time | Rewrite objects under new version prefix | 5–15 ms (object fetch) | Slowly-changing basemaps, huge read:write ratios |
Origin-pull CDN is the default and the right starting point: the CDN forwards misses to FastAPI unchanged and caches whatever the origin’s Cache-Control header permits. Edge compute moves coordinate validation, signed-URL checks, and cache-key normalisation into the PoP so invalid or unauthorised requests never consume an origin round-trip — the Cloudflare Workers edge routing walkthrough builds exactly this. Pre-rendered tile stores eliminate the database from the hot path entirely by baking every tile up to some zoom into object storage; they trade freshness and storage cost for the lowest possible miss latency, and are the natural extension of the batch generation described in Tile Generation & CDN Distribution.
Most production systems combine them: an origin-pull CDN for the broad request volume, a pre-rendered store for the low-zoom basemap tiles that everyone requests, and edge compute for authorization.
Step-by-Step Implementation
Step 1: Nail down the cache key
The cache key is the contract. For an origin-pull CDN the key is the path, so version and layer must live in the path. If you must accept a query parameter (for example ?fields=id,name), configure the CDN to include it in the key or strip it — never leave it default, or two different field selections will serve each other’s bytes. The minimal correct key is:
key = (version, layer, z, x, y)
Anything the response body depends on must appear in the key. If tiles differ by tenant, the tenant identity must be in the key too — usually derived from the signed token, not a raw header, to prevent cross-tenant cache bleed.
Step 2: Set Cache-Control correctly at the origin
Because the version is in the path, a versioned tile is immutable — its bytes can never change under that URL. That unlocks the strongest possible caching directive. The exact directive strategy, including s-maxage, stale-while-revalidate, and immutable, is the entire subject of Caching Vector Tiles at the Edge with Cache-Control. In short:
# Immutable versioned tile: browser and edge may hold it effectively forever
headers = {
"Cache-Control": "public, max-age=31536000, s-maxage=31536000, immutable",
"Content-Type": "application/vnd.mapbox-vector-tile",
}Step 3: Reject invalid coordinates before the database
This is a security control, not an optimisation. An unbounded z or an out-of-range x/y produces a unique key that will never be requested again, filling the edge cache with garbage and forcing an origin computation each time. Guard first, always.
Step 4: Choose origin generation vs pre-generation
For dynamic layers, generate on the origin at miss time (Step 5 below). For a basemap that changes weekly, pre-render every tile up to zoom 12 into an object store under a version prefix and point the CDN at it; a miss becomes an object fetch, not an ST_AsMVT. The break-even is roughly: pre-render when the same tile is read more than a few hundred times between data updates.
Step 5: Invalidate by bumping the version
When data changes, increment the version token (a table row, a Redis counter, or the max updated_at epoch) and expose the new value to the map client’s style JSON. New requests resolve to /tiles/v43/..., which misses everywhere and repopulates from the origin, while v42 tiles quietly expire. No purge storm, no partial-update flicker. Keep an emergency Cache-Tag / Surrogate-Key purge available for the rare case where you must evict a bad v42 immediately.
Production Code Example
A complete FastAPI origin route for the versioned MVT contract. It validates coordinates, verifies a signed token, runs ST_AsMVT, and sets immutable cache headers. This is the origin that both the Workers edge router and a plain origin-pull CDN sit in front of.
# app/routes/tiles.py
import hmac, hashlib, time
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response
router = APIRouter()
MAX_ZOOM = 15
TILE_SIGNING_KEY = b"rotate-me-from-secrets-manager"
TILE_CIRCUMFERENCE_M = 40075016.68557849 # EPSG:3857 circumference
ORIGIN_SHIFT = TILE_CIRCUMFERENCE_M / 2
def tile_to_bounds_3857(z: int, x: int, y: int) -> tuple[float, float, float, float]:
n = 2.0 ** z
return (
(x / n) * TILE_CIRCUMFERENCE_M - ORIGIN_SHIFT,
ORIGIN_SHIFT - ((y + 1) / n) * TILE_CIRCUMFERENCE_M,
((x + 1) / n) * TILE_CIRCUMFERENCE_M - ORIGIN_SHIFT,
ORIGIN_SHIFT - (y / n) * TILE_CIRCUMFERENCE_M,
)
def verify_tile_token(layer: str, token: str, exp: int) -> bool:
# Signed access: token authorises (layer, expiry). Constant-time compare.
if exp < int(time.time()):
return False
msg = f"{layer}:{exp}".encode()
expected = hmac.new(TILE_SIGNING_KEY, msg, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, token)
TILE_SQL = """
SELECT ST_AsMVT(q, $6, 4096, 'geom') AS tile
FROM (
SELECT
ST_AsMVTGeom(
ST_Transform(geom, 3857),
ST_MakeEnvelope($1, $2, $3, $4, 3857),
4096, 256, true
) AS geom,
id, name, category
FROM spatial_features
WHERE layer_name = $6
AND geom && ST_Transform(ST_MakeEnvelope($1, $2, $3, $4, 3857), 4326)
AND data_version <= $5 -- serve the snapshot pinned by the URL
) AS q
WHERE q.geom IS NOT NULL
"""
@router.get("/tiles/{version}/{layer}/{z}/{x}/{y}.mvt")
async def get_tile(
request: Request,
version: int, layer: str, z: int, x: int, y: int,
token: str = "", exp: int = 0,
):
# 1. Reject out-of-range coordinates BEFORE any database work (see Failure Modes)
if not (0 <= z <= MAX_ZOOM and 0 <= x < 2 ** z and 0 <= y < 2 ** z):
raise HTTPException(status_code=400, detail="Invalid tile coordinates")
# 2. Authorize: signed token scoped to the layer, short expiry
if not verify_tile_token(layer, token, exp):
raise HTTPException(status_code=403, detail="Invalid or expired tile token")
minx, miny, maxx, maxy = tile_to_bounds_3857(z, x, y)
async with request.app.state.pool.acquire() as conn:
row = await conn.fetchrow(TILE_SQL, minx, miny, maxx, maxy, version, layer)
tile_bytes = row["tile"] if row and row["tile"] else b""
# 3. Immutable because the version is in the path — bytes can never change here
return Response(
content=tile_bytes,
media_type="application/vnd.mapbox-vector-tile",
headers={
"Cache-Control": "public, max-age=31536000, s-maxage=31536000, immutable",
"ETag": f'"{version}-{layer}-{z}-{x}-{y}"',
"Vary": "Accept-Encoding",
},
)Returning an empty 200 (not 404) for a sparsely populated tile is deliberate: map clients treat 404 as an error and retry aggressively, and a 404 is far harder to cache than an empty 200.
Verification & Testing
Fetch a tile and inspect the caching headers:
curl -sI "https://cdn.example.com/tiles/42/roads/10/512/340.mvt?token=abc&exp=1799999999"HTTP/2 200
content-type: application/vnd.mapbox-vector-tile
cache-control: public, max-age=31536000, s-maxage=31536000, immutable
cf-cache-status: HIT # or MISS on first fetch, then HIT
age: 214
etag: "42-roads-10-512-340"
vary: accept-encoding
The second identical request must show cf-cache-status: HIT (Cloudflare), x-cache: Hit from cloudfront (CloudFront), or x-cache: HIT (Fastly), and a non-zero age. If it stays MISS, the origin is not sending a cacheable Cache-Control, or a query parameter is fragmenting the key.
Confirm the coordinate guard rejects abuse:
curl -sI "https://cdn.example.com/tiles/42/roads/30/9/9.mvt?token=abc&exp=1799999999" | head -1
# HTTP/2 400 ← z=30 is above MAX_ZOOM; never reaches the databaseConfirm the MVT decodes with a quick assertion against the origin directly (bypassing the edge):
import httpx, mapbox_vector_tile
r = httpx.get("http://origin:8000/tiles/42/roads/10/512/340.mvt",
params={"token": tok, "exp": exp})
assert r.status_code == 200
assert r.headers["content-type"] == "application/vnd.mapbox-vector-tile"
decoded = mapbox_vector_tile.decode(r.content)
assert "roads" in decoded # layer present in the tileFailure Modes & Edge Cases
Cache poisoning via unkeyed inputs. If the response varies on something not in the cache key — an unnormalised
Accept-Encoding, a?fields=param the CDN ignores, or a header the origin reads — an attacker can seed the edge with a response that other users then receive. Fix: everything the body depends on must be in the key, and setVary: Accept-Encodingso compressed and uncompressed variants never collide. Never let the origin vary on an uncontrolled request header.Stale tiles after a data update. Bytes were cached under
v42with a one-year TTL, data changed, and clients still see old geometry. Fix: never mutate a tile in place — bump the version segment on publish (Step 5) so the URL itself changes. If you cached mutable tiles without a version, you are forced into a full-path purge, which is slow and eventually consistent.Unbounded zoom / coordinate flood. A request for
z=30orx=2**zforces anST_AsMVTover a nonsensical envelope and creates a never-reused key, a cache-fill denial-of-service. Fix: the0 <= z <= MAX_ZOOM and 0 <= x,y < 2**zguard in Step 3, returning400before the pool is touched.Query-string key fragmentation. A signed
?token=and?exp=in the URL become part of the key on some CDNs, so every user’s unique token creates a unique cache entry and the hit ratio collapses to zero. Fix: verify the token at an edge worker and strip it before the cache lookup, or configure the CDN to ignoretoken/expin the cache key while still forwarding them to the origin.404storms on empty tiles. Returning404for tiles with no features makes clients retry and defeats caching. Fix: return an empty200with the same immutable headers; the edge caches “this tile is empty” and stops the retries.Origin serves
no-cacheby accident. A global middleware or reverse proxy injectsCache-Control: no-cacheand the edge dutifully forwards every request to the origin. Fix: assert on the header in an integration test (curl -I), and make the tile route set its ownCache-Controllast so it wins.Version bump without client awareness. You bump to
v43but the map style still requestsv42, so clients see stale tiles until they reload the style. Fix: derive the tile URL template in the style JSON from the same version source, and set a short TTL on the style document itself.
Performance Notes
Edge hit ratio is the metric that matters. At a 95% edge hit ratio, only 1 in 20 tile requests reaches the origin; at 99% it is 1 in 100. The difference is enormous: a map with 10,000 tile req/s at 95% still sends 500 req/s of ST_AsMVT to PostGIS, but at 99% it sends 100 req/s. Immutable versioned tiles trend toward 99%+ because a given (version, z, x, y) is requested identically by every user in a region and never invalidated mid-life.
Origin offload compounds with pre-rendering. Baking low-zoom tiles (z 0–8, only ~87,000 tiles worldwide) into an object store removes the most-requested, most-expensive-to-aggregate tiles from the database entirely. The origin then only computes high-zoom, low-traffic tiles on demand.
Miss latency is dominated by ST_AsMVT. A cold tile is 20–200 ms depending on feature density; an edge hit is 5–15 ms including network. This is why keeping the origin pool healthy still matters — the surviving miss traffic must be served fast so the edge repopulates quickly after a version bump. Route the origin through PgBouncer as described in Connection Pooling & PgBouncer Setup, and put a Redis hot-tile cache between FastAPI and PostGIS to absorb the thundering-herd of misses that follows every publish.
Compression happens once. MVTs are already compact protobuf, but gzip still shaves 20–40%. Let the edge compress and cache the compressed variant — do not compress on the origin per request, or you burn CPU on every miss for no additional cache benefit.
FAQ
Should the CDN cache key include the tile layer and data version?
Yes. The minimal correct cache key is z, x, y, layer, and a data-version token. Omitting the layer causes two layers requesting the same z/x/y to collide and serve each other’s bytes. Omitting the version means you can never invalidate atomically — you are stuck purging every path or waiting for TTL expiry. Encode the version in the path (/tiles/v42/roads/10/512/340.mvt) so it becomes part of the key for free.
How do I invalidate CDN tiles the moment underlying data changes?
Do not purge z/x/y paths one by one — a single metro area at zoom 14 is tens of thousands of tiles. Instead put a monotonic version segment in the tile path and bump it on publish. New requests resolve to a fresh key that misses the edge and repopulates from the origin, while old versioned tiles age out by TTL. Pair this with a Cache-Tag or Surrogate-Key purge for emergency invalidation.
How do I stop an attacker from requesting unbounded zoom levels?
Reject out-of-range coordinates before any database work: enforce 0 <= z <= max_zoom and 0 <= x, y < 2**z, returning 400 for anything outside that box. Without the guard, a request for z=30 forces the origin to compute an ST_AsMVT over an absurd envelope and pollutes the edge with millions of unique, never-reused keys — a cache-fill denial-of-service.
Related
- Tile Generation & CDN Distribution — the
ST_AsMVTorigin query, Web Mercator math, and Redis hot-tile layer this page sits on top of - Cloudflare Workers Edge Routing for Vector Tile Endpoints — a Worker that validates coordinates, checks the Cache API, and fetches from the origin on a miss
- Caching Vector Tiles at the Edge with Cache-Control — the exact directive strategy for immutable versioned vs mutable tiles
- Connection Pooling & PgBouncer Setup — keep the origin pool healthy under the miss traffic that survives the edge
- Redis Caching for Spatial Queries — absorb the thundering-herd of misses that follows every version bump
← Back to Deploying & Operating Geospatial APIs