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 paths: dynamic tile server versus static PMTiles range requests A container box splits into two paths. Path A: MBTiles to a Tile Server (Martin or tileserver-gl) to a CDN to the browser. Path B: PMTiles on object storage (R2 or S3) directly to a CDN via HTTP range requests to the browser. Both paths converge on the same CDN edge cache and browser. Container build output .mbtiles .pmtiles Tile Server Martin / tileserver-gl Object Storage R2 / S3 range reads CDN edge cache-control TTL Browser MapLibre GL Path A — dynamic server Path B — static range requests

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.

bash
# 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:

bash
# 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:

bash
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:

bash
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:

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


Next reading CDN Cache Headers & Versioned Tile URLs Next reading PMTiles Range-Request Delivery from Object Storage Next reading Tile Server Selection: Martin, tileserver-gl & pg_tileserv