← Back to High-Performance Caching & Query Optimization
Delivering interactive maps at scale requires a coordinated pipeline between your spatial database, application server, and edge network. This page covers how to generate Mapbox Vector Tiles directly from PostGIS using ST_AsMVT, configure FastAPI to stream binary tile responses, and wire a CDN to absorb the bulk of requests before they ever reach your origin — achieving consistent sub-50ms tile delivery worldwide. This workflow sits at the heart of the broader High-Performance Caching & Query Optimization stack, where database efficiency, async streaming, and edge caching converge to eliminate redundant computation.
Prerequisites & Environment
Before deploying a production-ready tile pipeline, verify these infrastructure requirements. Skipping them leads to thread exhaustion, cache fragmentation, or inconsistent tile rendering across zoom levels.
| Requirement | Minimum version | Why it matters |
|---|---|---|
| PostgreSQL | 14+ | Improved parallel query execution for spatial aggregation |
| PostGIS | 3.2+ | Critical ST_AsMVT memory and geometry aggregation fixes |
| FastAPI | 0.100+ | Lifespan-based connection pool management |
| asyncpg | 0.29+ | Non-blocking pool, binary protocol for PostGIS types |
| CDN provider | Any (Cloudflare / CloudFront / Fastly) | Must cache application/vnd.mapbox-vector-tile MIME type |
Your geometry column must have a GiST spatial index in place. Without it, the tile bounding box filter degrades into a full table scan — see Query Plan Analysis & Index Tuning for how to create and verify those indexes using EXPLAIN ANALYZE. Source data should be stored in EPSG:4326 (WGS 84) or EPSG:3857 (Web Mercator); although ST_Transform handles on-the-fly reprojection, pre-transformed data reduces CPU overhead under concurrent tile generation.
Decision Matrix: Tile Caching Layers
The tile pipeline has three independent caching surfaces. Understanding where each layer fits prevents over-engineering and clarifies cache invalidation scope.
| Layer | Scope | Latency reduction | Invalidation mechanism | Best for |
|---|---|---|---|---|
| CDN edge cache | Global PoP network | ~40–120 ms → ~5–15 ms | Cache-Tag purge or TTL expiry | Static or infrequently updated layers |
| Redis tile cache | Application server | ~5–30 ms → ~0.3–1 ms | Key-based DEL, pattern SCAN + DEL | Hot tiles (metro areas, zoom 10–14) |
| PostgreSQL query cache | Database (shared_buffers) | ~50–500 ms → ~2–10 ms | Automatic (LRU) | Repeated identical bounding box queries |
For most production workloads, combine CDN caching for the broad request volume with Redis Caching for Spatial Queries for the hottest tiles. PostgreSQL’s internal caching handles repeated identical queries automatically once buffer hit rates are high.
Architecture: The Tile Request Pipeline
The diagram below shows the full request path from map client through CDN, application layer, Redis, and PostGIS.
Step-by-Step Implementation
1. Install dependencies
pip install fastapi uvicorn asyncpg redisPostGIS 3.2+ must be enabled in your database:
CREATE EXTENSION IF NOT EXISTS postgis;
SELECT postgis_full_version();2. Define the tile coordinate-to-bounds conversion
Web Mercator tile coordinates map to EPSG:3857 bounding boxes via a deterministic formula. Implement this once and reuse it across all tile endpoints:
TILE_CIRCUMFERENCE_M = 40075016.68557849 # Earth circumference in meters (EPSG:3857)
ORIGIN_SHIFT = TILE_CIRCUMFERENCE_M / 2
def tile_to_bounds_3857(z: int, x: int, y: int) -> tuple[float, float, float, float]:
"""Convert tile Z/X/Y to Web Mercator bbox (minx, miny, maxx, maxy) in EPSG:3857."""
n = 2.0 ** z
x_min = (x / n) * TILE_CIRCUMFERENCE_M - ORIGIN_SHIFT
y_max = ORIGIN_SHIFT - (y / n) * TILE_CIRCUMFERENCE_M
x_max = ((x + 1) / n) * TILE_CIRCUMFERENCE_M - ORIGIN_SHIFT
y_min = ORIGIN_SHIFT - ((y + 1) / n) * TILE_CIRCUMFERENCE_M
return x_min, y_min, x_max, y_max3. Build the PostGIS tile query
ST_AsMVT aggregates geometries clipped and scaled to the tile grid. ST_AsMVTGeom handles the clipping and coordinate scaling into the 0–4096 tile coordinate space. The WHERE clause must use the geometry column’s native SRID for the GiST index to engage — this is the most common source of slow tile queries.
For layers with geometry stored in EPSG:4326:
SELECT ST_AsMVT(q, $5, 4096, 'geom') AS tile
FROM (
SELECT
ST_AsMVTGeom(
ST_Transform(geom, 3857),
ST_MakeEnvelope($1, $2, $3, $4, 3857),
4096, 256, true -- extent, buffer, clip_geom
) AS geom,
id,
name,
category
FROM spatial_features
WHERE geom && ST_Transform(
ST_MakeEnvelope($1, $2, $3, $4, 3857), 4326
)
) AS q
WHERE q.geom IS NOT NULLThe 256-unit buffer clips geometries extending slightly beyond the tile boundary, which prevents visual gaps at tile edges on the client side. Adjust this value if your features span large areas relative to the tile size.
4. Configure the asyncpg connection pool
Use FastAPI’s lifespan context to initialise the pool once at startup and close it cleanly on shutdown. Sync database adapters like psycopg2 block the event loop and exhaust worker threads under concurrent tile load — asyncpg’s binary protocol avoids both problems. For managing the connection pool under heavy traffic, review the Connection Pooling & PgBouncer Setup guide, which covers PgBouncer transaction-mode pooling for spatial workloads.
from contextlib import asynccontextmanager
import asyncpg, os
@asynccontextmanager
async def lifespan(app):
dsn = os.getenv("DATABASE_URL")
app.state.pool = await asyncpg.create_pool(
dsn=dsn,
min_size=5,
max_size=20,
command_timeout=10, # abort slow tile queries rather than queue indefinitely
)
yield
await app.state.pool.close()5. Wire the tile endpoint
import math
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import Response
import asyncpg
app = FastAPI(title="Geospatial Tile API", lifespan=lifespan)
@app.get("/tiles/{z}/{x}/{y}.pbf")
async def get_tile(
z: int,
x: int,
y: int,
layer: str = Query("features", description="PostGIS layer name (table)"),
):
# Reject out-of-range coordinates before touching the database
if z < 0 or z > 20 or x < 0 or x >= 2**z or y < 0 or y >= 2**z:
raise HTTPException(status_code=400, detail="Invalid tile coordinates")
minx, miny, maxx, maxy = tile_to_bounds_3857(z, x, y)
tile_query = """
SELECT ST_AsMVT(q, $5, 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 geom && ST_Transform(
ST_MakeEnvelope($1, $2, $3, $4, 3857), 4326
)
) AS q
WHERE q.geom IS NOT NULL
"""
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow(tile_query, minx, miny, maxx, maxy, layer)
if not row or not row["tile"]:
# Return a valid empty MVT rather than 404 — map clients treat 404 as an error
# and log aggressively for sparsely populated tiles
return Response(
content=b"",
media_type="application/vnd.mapbox-vector-tile",
headers={
"Cache-Control": "public, max-age=86400",
"Cache-Tag": f"tiles,layer-{layer}",
},
)
return Response(
content=bytes(row["tile"]),
media_type="application/vnd.mapbox-vector-tile",
headers={
"Cache-Control": "public, max-age=86400, immutable",
"Cache-Tag": f"tiles,layer-{layer}",
"Vary": "", # remove implicit Vary: Accept so the CDN doesn't fragment the cache
},
)The Cache-Tag header enables tag-based CDN purge: after a data update to a specific layer, issue a single purge for layer-{name} rather than iterating over thousands of z/x/y paths.
Production Code Example
Below is the complete, copy-runnable implementation combining the pool, coordinate conversion, Redis tile cache, and the tile endpoint. The Redis layer sits between FastAPI and PostGIS, serving serialised MVT payloads for hot tiles without touching the database.
import math, os
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import Response
import asyncpg
import redis.asyncio as aioredis
TILE_CIRCUMFERENCE_M = 40075016.68557849
ORIGIN_SHIFT = TILE_CIRCUMFERENCE_M / 2
def tile_to_bounds_3857(z, x, y):
n = 2.0 ** z
x_min = (x / n) * TILE_CIRCUMFERENCE_M - ORIGIN_SHIFT
y_max = ORIGIN_SHIFT - (y / n) * TILE_CIRCUMFERENCE_M
x_max = ((x + 1) / n) * TILE_CIRCUMFERENCE_M - ORIGIN_SHIFT
y_min = ORIGIN_SHIFT - ((y + 1) / n) * TILE_CIRCUMFERENCE_M
return x_min, y_min, x_max, y_max
@asynccontextmanager
async def lifespan(app):
app.state.pool = await asyncpg.create_pool(
dsn=os.getenv("DATABASE_URL"), min_size=5, max_size=20, command_timeout=10
)
app.state.redis = aioredis.from_url(
os.getenv("REDIS_URL", "redis://localhost:6379"),
decode_responses=False, # tiles are binary; never decode
)
yield
await app.state.pool.close()
await app.state.redis.aclose()
app = FastAPI(title="Geospatial Tile API", lifespan=lifespan)
TILE_TTL = 3600 # Redis TTL in seconds — align with your data refresh cadence
@app.get("/tiles/{z}/{x}/{y}.pbf")
async def get_tile(
z: int, x: int, y: int,
layer: str = Query("features"),
):
if z < 0 or z > 20 or x < 0 or x >= 2**z or y < 0 or y >= 2**z:
raise HTTPException(status_code=400, detail="Invalid tile coordinates")
cache_key = f"tile:{layer}:{z}:{x}:{y}"
cached = await app.state.redis.get(cache_key)
if cached is not None:
return Response(
content=cached,
media_type="application/vnd.mapbox-vector-tile",
headers={
"Cache-Control": "public, max-age=86400, immutable",
"Cache-Tag": f"tiles,layer-{layer}",
"X-Cache": "HIT",
},
)
minx, miny, maxx, maxy = tile_to_bounds_3857(z, x, y)
query = """
SELECT ST_AsMVT(q, $5, 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 geom && ST_Transform(ST_MakeEnvelope($1,$2,$3,$4,3857), 4326)
) AS q
WHERE q.geom IS NOT NULL
"""
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow(query, minx, miny, maxx, maxy, layer)
tile_bytes = bytes(row["tile"]) if row and row["tile"] else b""
if tile_bytes:
await app.state.redis.set(cache_key, tile_bytes, ex=TILE_TTL)
return Response(
content=tile_bytes,
media_type="application/vnd.mapbox-vector-tile",
headers={
"Cache-Control": "public, max-age=86400" + (", immutable" if tile_bytes else ""),
"Cache-Tag": f"tiles,layer-{layer}",
"X-Cache": "MISS",
"Vary": "",
},
)Verification & Testing
Fetch a tile from the running server
# Download a tile at zoom 10, tile 512/341 (central Europe)
curl -v -o tile.pbf \
"http://localhost:8000/tiles/10/512/341.pbf?layer=features"
# Verify it is a valid Protocol Buffer (non-zero binary)
wc -c tile.pbf
# Expected: > 0 bytes for a populated area
file tile.pbf
# Expected: data (binary)Confirm the GiST index is used
Run EXPLAIN ANALYZE on the bounding box filter to verify index usage. Missing a GiST index is the single most common cause of slow tile generation.
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, name
FROM spatial_features
WHERE geom && ST_Transform(
ST_MakeEnvelope(
1113194.9, 6274861.4, -- minx, miny (EPSG:3857 for z10 tile 512/341)
1172006.4, 6340869.2, -- maxx, maxy
3857
), 4326
);
-- Look for: "Index Scan using spatial_features_geom_idx"
-- Red flag: "Seq Scan" with a large row count means the index is missing or not being usedUnit test skeleton
import pytest
from httpx import AsyncClient, ASGITransport
from your_app import app # replace with your module
@pytest.mark.asyncio
async def test_tile_valid_coordinates():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
resp = await ac.get("/tiles/10/512/341.pbf?layer=features")
assert resp.status_code == 200
assert resp.headers["content-type"] == "application/vnd.mapbox-vector-tile"
assert "public" in resp.headers["cache-control"]
@pytest.mark.asyncio
async def test_tile_invalid_coordinates():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
resp = await ac.get("/tiles/10/9999/9999.pbf")
assert resp.status_code == 400Failure Modes & Edge Cases
CDN bypasses binary tile responses. Some CDN configurations skip caching for unknown MIME types. Explicitly allow
application/vnd.mapbox-vector-tilein your CDN cache rules. On Cloudflare, add a Cache Rule matching the/tiles/*path with “Cache Everything” enabled.Vary: Accept-Encodingfragmenting the cache. If FastAPI or an upstream middleware addsVary: Accept-Encoding, the CDN treats gzip and non-gzip responses as separate cache entries, halving effective hit rates. Set"Vary": ""explicitly on tile responses, or disable automatic compression for.pbfpaths.GiST index not used due to SRID mismatch. If the
ST_Transformin theWHEREclause produces an SRID that does not match the geometry column, PostgreSQL falls back to a sequential scan. Verify withSELECT Find_SRID('public', 'spatial_features', 'geom')and align the transform accordingly.ST_AsMVTreturnsNULLinstead of empty bytes. Accessingrow["tile"]on a row where all features were clipped out of bounds can returnNonerather than an empty bytes value. Always guard withif row and row["tile"]before callingbytes().asyncpg pool exhaustion under burst load. If
max_sizeis set too low relative to concurrent tile requests,asyncpgraisesasyncpg.exceptions.TooManyConnectionsError. Increasemax_sizeor, for workloads exceeding 500 concurrent requests, front the database with PgBouncer in transaction mode as described in Connection Pooling & PgBouncer Setup.Empty tile 404 causing client error loops. Map clients like MapLibre GL and OpenLayers interpret 404 responses for tile URLs as hard errors and may disable the layer or log continuously. Return
200with empty content (b"") and a validCache-Controlheader so the CDN caches the empty result and the client renders a blank (not broken) tile.Redis storing decoded strings for binary tiles. Using
decode_responses=Trueon the Redis connection will corrupt binary MVT payloads during storage and retrieval. Always initialise the Redis client withdecode_responses=Falsewhen caching tile bytes.
Performance Notes
Latency by layer. Cold requests hitting PostGIS typically return in 20–200 ms depending on geometry complexity and zoom level. Redis hits reduce this to 0.3–1 ms; CDN hits to 5–15 ms including network round-trip to the nearest PoP.
Index impact. A GiST index on geom reduces bounding box query time from full table scans (hundreds of milliseconds on tables > 1 M rows) to 1–5 ms index scans. On the Query Plan Analysis & Index Tuning page, use EXPLAIN (ANALYZE, BUFFERS) to measure buffer hit rates and confirm the index is engaged at each zoom level.
Zoom level selectivity. Low zoom levels (z ≤ 8) cover enormous bounding boxes and may return millions of features. Apply zoom-dependent simplification using ST_Simplify before ST_AsMVTGeom, or use pre-generated tile layers stored in a tile cache like Martin or pg_tileserv to avoid real-time aggregation at coarse zoom levels.
Async vs sync trade-offs. A synchronous FastAPI endpoint backed by psycopg2 blocks one worker thread per request. With 4 Uvicorn workers and 100 concurrent tile requests, you will exhaust the thread pool immediately. asyncpg’s non-blocking pool allows a single worker to handle hundreds of concurrent in-flight queries without spawning threads.
CDN cache hit ratio. Monitor CDN hit rates broken down by zoom level. Zoom levels 10–14 over populated areas should reach 85–95% hit ratios within minutes of a cold start. Zoom levels 0–6 (global) hit 99%+ quickly. Zoom 18+ requests are often unique and benefit less from CDN caching; consider Redis as the primary cache for fine-grained zoom levels.
HTTP Caching Strategy & Edge Invalidation
Vector tiles are deterministic: identical z/x/y/layer parameters always produce identical binary output given the same underlying data. This makes them ideal CDN cache targets.
| Data change frequency | Recommended headers | Invalidation method |
|---|---|---|
| Static (never changes) | public, max-age=31536000, immutable | None needed |
| Weekly updates | public, max-age=604800 | Cache-Tag purge on deploy |
| Daily updates | public, max-age=86400 | Cache-Tag purge after ETL job |
| Hourly updates | public, max-age=3600, stale-while-revalidate=86400 | Cache-Tag purge after ingestion |
| Near-real-time | public, max-age=60, stale-while-revalidate=300 | Redis TTL-based expiry |
Tag-based purge is far faster than path-based purge. Emit Cache-Tag: tiles,layer-{layer_name} on every response. When a data update completes for a specific layer, issue a single tag purge for layer-{layer_name} — this invalidates all affected tiles simultaneously regardless of their z/x/y coordinates.
Why does my tile endpoint return 200 with empty content for populated areas?
The most common cause is an SRID mismatch in the WHERE clause. If your geometry column is in EPSG:4326 but the envelope is not transformed back to 4326 before the && operator, the spatial filter may return no rows even for populated tiles. Run SELECT Find_SRID('public', 'spatial_features', 'geom') and confirm your ST_Transform target SRID matches the column’s SRID exactly.
How do I serve multiple layers from one endpoint?
Pass the layer name as a query parameter (?layer=roads) and use it as the $5 argument to ST_AsMVT. Validate the layer name against an allowlist before interpolating it into the query — even though it is passed as a parameter to ST_AsMVT, the table name in the FROM clause must not be built via string concatenation. Use a mapping dict (ALLOWED_LAYERS = {"roads": "road_features", ...}) and look up the actual table name server-side.
What is the correct way to purge Cloudflare tile caches after a data update?
Use Cloudflare’s Cache-Tag purge API. Include the Cache-Tag: layer-{name} response header on every tile response, then call POST /zones/{zone_id}/purge_cache with {"tags": ["layer-roads"]} after each data update. This purges all z/x/y tiles for that layer simultaneously. You must enable Cloudflare’s Cache Rules to include the tag header, and the zone plan must support tag-based purge (Business or Enterprise).
Related
- High-Performance Caching & Query Optimization — parent section covering the full caching stack
- Redis Caching for Spatial Queries — binary tile caching, TTL strategies, and cache invalidation patterns
- Connection Pooling & PgBouncer Setup — decoupling application concurrency from database connection limits
- Query Plan Analysis & Index Tuning — verifying GiST index usage with
EXPLAIN ANALYZEfor spatial queries - Bounding Box Spatial Index Queries —
ST_WithinandST_Intersectspatterns that underpin the tile bounding box filter