Tile Serving & CDN Delivery
Tile serving is the stage of the pipeline that turns a finished tile container into HTTP responses a map client can fetch, and CDN delivery is the caching layer that makes those responses fast and cheap at scale. This is where a correct-but-slow tileset becomes a production map: the choice between a dynamic tile server and a static PMTiles object, the cache-control headers on every path, and the version strategy that lets you redeploy without purging the world.
How This Fits the Full Pipeline
The canonical pipeline runs: raw spatial data (GeoJSON or GeoParquet) → tile generation with Tippecanoe → a container (MBTiles SQLite file or a single-file PMTiles archive) → serving + CDN → the browser renderer driven by a MapLibre GL style. Everything upstream of delivery decides what a tile contains; this stage decides how fast and how reliably it reaches the client, and whether a redeploy causes a cache stampede.
Two delivery models dominate production. In the dynamic model a tile server reads an MBTiles file (or PostGIS) and answers /{z}/{x}/{y}.pbf requests, and a CDN caches those responses. In the static model a single .pmtiles archive sits on object storage and the client (or a thin worker) fetches byte ranges directly, so there is no server process to run at all. Most of the operational questions below — cache headers, versioning, invalidation — apply to both.
Core Delivery Parameters
| Parameter | Where it lives | What breaks if wrong |
|---|---|---|
Cache-Control |
CDN / origin response header | Tiles re-fetched every load, or stale tiles pinned for a year |
ETag / Last-Modified |
origin response header | Revalidation 304s stop working; full re-downloads |
Accept-Ranges: bytes |
object-storage response header | PMTiles range reads fail; client downloads the whole archive |
Content-Encoding: gzip |
tile response header | Double-gzip or MapLibre fails to parse a .pbf |
Access-Control-Allow-Origin |
CDN / origin response header | Cross-origin tile fetches blocked by the browser |
| version prefix in tile URL | /{hash}/{z}/{x}/{y}.pbf path |
A rebuild silently mixes old tiles with a new style |
The Mapbox Vector Tile payload is protobuf and must be served with Content-Type: application/x-protobuf (or application/vnd.mapbox-vector-tile) and, when pre-compressed, Content-Encoding: gzip. Tippecanoe writes gzipped tiles into MBTiles by default, so a naive static server that adds its own gzip layer produces a double-encoded body that MapLibre cannot decode.
Implementation Patterns
Pattern 1 — Static PMTiles behind a CDN
The lowest-operations option: upload one archive, point the CDN at the bucket, and let the client read ranges.
# Convert an MBTiles build to a single PMTiles archive, then publish it
pmtiles convert roads.mbtiles roads-$(date +%Y%m%d).pmtiles
aws s3 cp roads-20260712.pmtiles s3://tiles.example.com/v3a1b2c3/roads.pmtiles \
--content-type application/octet-stream \
--cache-control "public, max-age=31536000, immutable"
The style then references pmtiles://https://tiles.example.com/v3a1b2c3/roads.pmtiles, and the CDN caches each requested byte range. Object-storage range-request delivery is covered in depth in its own section, including the Accept-Ranges and CORS headers R2 and S3 need.
Pattern 2 — Dynamic MBTiles server behind a CDN
When tiles change often or are generated per-request from PostGIS, a tile server is the right layer:
# Serve an MBTiles file over HTTP with Martin, then cache at the CDN
martin roads.mbtiles --listen-addresses 0.0.0.0:3000
# Origin URL: http://origin:3000/roads/{z}/{x}/{y}
# Front it with a CDN and set Cache-Control on the CDN response, not the origin.
Choosing between Martin, tileserver-gl, and pg_tileserv is a real decision with throughput and feature trade-offs — tile server selection walks through it.
Pattern 3 — Versioned prefix rotation
Hard-coding an unversioned tile URL means a rebuild instantly poisons every edge cache. Instead, publish each build under a schema-hash prefix and swap the style’s sources.url pointer:
SCHEMA_HASH=$(pmtiles show roads.pmtiles --header-json | \
python3 -c "import sys,json,hashlib;print(hashlib.sha1(sys.stdin.read().encode()).hexdigest()[:8])")
aws s3 cp roads.pmtiles s3://tiles.example.com/v${SCHEMA_HASH}/roads.pmtiles \
--cache-control "public, max-age=31536000, immutable"
Old and new builds coexist under different prefixes, so a deploy never needs a global purge. This is the mechanism behind versioned tile URL rotation.
Performance & Scale Considerations
| Concern | Threshold / target | Mitigation |
|---|---|---|
| Edge cache hit ratio | > 95% for a stable basemap | Immutable versioned paths; avoid per-request query strings |
| Origin egress cost | minimize | Cache at the CDN, not the origin; PMTiles on R2 has zero egress fees |
| Range-request overhead | 1–2 extra requests on first load | PMTiles directory caching; HTTP/2 to amortize round-trips |
| Cold-cache tile latency | < 150 ms at the edge | Pre-warm the new version prefix before flipping the style pointer |
| Tile size | ≤ 500 KB per tile | Fix upstream via attribute filtering and simplification, not at delivery |
Delivery cannot fix an oversized tile — a 900 KB tile is slow no matter how well it is cached. Tile-size budgets are enforced during generation through geometry simplification and zoom-level strategy; the delivery layer’s job is to make an already-lean tile fast.
Storage & Delivery Choice
The container format decision propagates directly into how you serve and cache. The trade-off between PMTiles and MBTiles for CDN delivery is the single most consequential call in this stage:
| MBTiles + tile server | PMTiles on object storage | |
|---|---|---|
| Moving parts | server process + CDN | bucket + CDN (no server) |
| Cache key | /{z}/{x}/{y}.pbf path |
HTTP byte range of one object |
| Redeploy | swap file, restart/ reload server | upload new object under new prefix |
| Egress | origin egress unless fully cached | zero on R2; range-billed on S3 |
| Dynamic data | native (PostGIS ST_AsMVT) |
rebuild required |
Cache-control strategy is where most delivery incidents originate; the cache headers and versioning section is the reference for max-age, immutable, stale-while-revalidate, and the short-TTL rules for style JSON.
Failure Modes & Debugging
Failure: Tiles load but render blank (double gzip)
Symptom: HTTP 200, correct byte count, but MapLibre logs a protobuf parse error.
Diagnosis:
curl -sI "https://tiles.example.com/v3/10/301/384.pbf" | grep -i content-encoding
# Then check the raw magic bytes — a gzip body starts with 1f 8b
curl -s "https://tiles.example.com/v3/10/301/384.pbf" | xxd | head -1
Fix: If the tiles are already gzipped in the container, the server/CDN must send Content-Encoding: gzip and must not re-compress. Disable the CDN’s automatic compression on .pbf paths.
Failure: PMTiles fetch downloads the entire archive
Symptom: First map load pulls hundreds of MB; network tab shows one giant request instead of small ranges.
Diagnosis:
curl -sI "https://tiles.example.com/v3/roads.pmtiles" | grep -i accept-ranges
Fix: The origin is not advertising Accept-Ranges: bytes, so the client falls back to a full GET. Configure the bucket/CDN to honor range requests — see debugging range-request failures.
Failure: A redeploy spikes origin traffic 10×
Symptom: Right after a style deploy, origin request rate jumps for 30–90 seconds.
Diagnosis: The new style points at a version prefix with no warm edge entries, so every client misses simultaneously.
Fix: Pre-warm the new prefix with synthetic requests before flipping the style sources.url, or serve both prefixes during a short dual-deploy window. This is why immutable cache-control headers pair with prefix rotation rather than global purges.
Failure: Cross-origin tile requests blocked
Symptom: Browser console shows No 'Access-Control-Allow-Origin' header and every tile fails.
Fix: Add Access-Control-Allow-Origin: * (or the map’s origin) on the CDN response for tile and PMTiles paths. Object stores do not send CORS headers by default.
Explore This Topic
Each area below deepens one part of getting tiles to the browser:
CDN Cache Headers & Versioned Tile URLs — the Cache-Control reference for tiles versus style JSON, immutable and stale-while-revalidate semantics, and rotating a version prefix so deploys never need a global purge.
PMTiles Range-Request Delivery from Object Storage — hosting a single .pmtiles archive on Cloudflare R2 or Amazon S3, the Accept-Ranges/CORS headers required, and how the directory-then-tile read pattern maps onto HTTP range requests.
Tile Server Selection: Martin, tileserver-gl & pg_tileserv — matching a serving engine to your source (MBTiles vs PostGIS), with throughput, feature-set, and operational trade-offs for each.
Related
- Vector Tile Architecture & Format Fundamentals — the MVT encoding and container formats that determine what is being delivered and how it must be labeled on the wire.
- PMTiles Specification Deep Dive — the archive layout and directory structure that makes single-file range-request hosting possible.
- MBTiles Architecture & Limits — the SQLite container a dynamic tile server reads, including the size and locking constraints that affect serving.
- Map Styling & Layer Synchronization — the style document whose
sources.urlmust point at the versioned tile path this stage publishes. - Automated Generation Pipelines with Tippecanoe — the build stage that emits the container and enforces the tile-size budgets delivery cannot fix.