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.
Deciding Where the Tiles Come From
Every delivery architecture on this page is one of three shapes, and the choice between them is settled by a single question: how often do the bytes of a tile change?
Bytes never change once published. This is the overwhelmingly common case — a basemap rebuilt weekly or quarterly, an overlay rebuilt nightly. A rebuild is not a change to an existing tile; it is a new set of tiles at a new address. Publish a PMTiles archive to object storage under a versioned prefix, mark it immutable, and there is no server on the read path at all. The origin is a bucket, the CDN holds byte ranges, and the operational surface is a file.
Bytes change on a schedule you control. An editable dataset patched through the day, where rebuilding the whole tileset each time is wasteful. An MBTiles file behind Martin or tileserver-gl lets individual tiles be replaced without republishing everything, at the cost of running and scaling that process. The CDN still does the heavy lifting; the server exists so that a partial update is possible.
Bytes reflect a live database. Incident feeds, vehicle positions, anything where a tile generated a minute ago is already wrong. Generate on demand from PostGIS with ST_AsMVT, cache at the edge for a short, deliberate TTL, and accept that the database is now on the request path. This is the expensive shape and it should be reserved for the layers that genuinely need it — usually one thin overlay above a static basemap, not the basemap itself.
Mixing shapes is normal and usually correct. A production map commonly serves a static PMTiles basemap, an MBTiles-backed overlay refreshed hourly, and a live PostGIS layer for the one thing that must be current. What is not correct is applying the third shape to all three, which is how teams end up scaling a database to serve a basemap that has not changed since the last quarter.
What the CDN Is Actually Caching
It is worth being precise about the cache key, because most delivery bugs are a cache key that is not what the team believes it is. For a plain tile server the key is the URL path, and every tile is an object. For PMTiles the key is the URL path plus the byte range, and a single archive becomes thousands of independently cached range objects at the edge.
That difference has two consequences worth internalising. First, a PMTiles archive does not need to fit in the edge cache — only the ranges readers actually request do, which for a global basemap is a small fraction of the file weighted heavily toward populated areas. Second, anything that rewrites the response body invalidates every offset in the archive’s directory at once. A CDN feature that compresses responses, injects headers into bodies, or applies any content transform will break range reads in a way that looks like data corruption rather than a configuration error.
The Request Budget for One Viewport
Delivery decisions are easier to argue about once the unit is fixed. A desktop viewport at 1440×900 shows roughly 12 to 16 tiles at a typical zoom; a phone shows 6 to 9. Every one of those is an HTTP request unless it is already in the browser’s memory cache, and each carries its own latency, its own chance of a cache miss, and its own contribution to the time before the map looks finished.
That gives a simple budget to design against:
- Cold viewport, one source, PMTiles. Three requests before the first tile can even be located — the header, the root directory, then the tiles themselves, coalesced into a handful of ranges. On a warm edge, all of them are served locally.
- Cold viewport, one source, tile server. One request per tile, plus the TileJSON. No directory hop, but every request that misses the edge reaches a process you are running.
- Each additional source multiplies the tile requests. Sources do not share tiles, so a style with four sources fetches four sets of tiles for the same screen. This is the single most common reason a map that renders instantly in development crawls in production, where the four sources sit behind four different origins.
The practical consequence: reducing the number of sources pays more than reducing the number of layers. Layers inside one tileset cost bytes; sources cost round trips, and round trips dominate on mobile networks where a request costs 100–200 ms before a single byte arrives.
CORS: the Header Set a Browser Actually Needs
Tiles fetched cross-origin need more than Access-Control-Allow-Origin. A range-reading client also needs the request header allowed and the response headers exposed, and the failure when they are missing is indistinguishable in the console from a network error:
# What a correctly configured range-capable origin returns
curl -sI -H "Origin: https://maps.example.com" -H "Range: bytes=0-99" \
https://tiles.example.com/v43/basemap.pmtiles | grep -iE \
"^(HTTP|access-control|accept-ranges|content-range)"
# HTTP/2 206
# access-control-allow-origin: https://maps.example.com
# access-control-expose-headers: content-range, accept-ranges, content-length
# accept-ranges: bytes
# content-range: bytes 0-99/4194304000
Three things must all hold. Access-Control-Allow-Origin must match the map’s origin including scheme and port. Range must be in the allowed request headers, or the browser’s preflight fails before the fetch is made. And Content-Range must be in the exposed headers, because without it the client can read the body but not learn the object’s total length — which is exactly what it needs in order to seek.
For a plain z/x/y tile server none of the range headers matter, but the origin check still does, and a tile server behind a CDN commonly needs Vary: Origin so that a response cached for one origin is not served to another.
Watching What the Edge Is Doing
Delivery problems are cheap to fix and expensive to notice, because a mis-configured cache does not fail — it just costs money and latency quietly. Three measurements are enough to catch nearly all of it:
- Cache hit ratio, split by path. An aggregate number hides the interesting cases. Tiles under a versioned prefix should sit above 99%; the style document will sit near zero by design, because its TTL is deliberately short. If the tile ratio drops, something is varying the cache key — a query string, a
Varyheader, or aSet-Cookiethe origin should not be sending. - Origin request rate against reader traffic. These two should be almost uncorrelated. Origin traffic that tracks reader traffic means the edge is not holding anything, whatever the hit-ratio dashboard says.
- The 4xx and 5xx mix on tile paths. A steady trickle of 404s usually means the style asks for a zoom the archive does not carry. A burst of 416s means an archive was replaced in place rather than published under a new prefix.
Each of these has a matching fix earlier in this section, which is the point: the delivery tier has few enough moving parts that a symptom maps to a cause almost mechanically.
Rollback
The rollback story is the clearest argument for the versioned-prefix approach, and it is worth stating plainly because it is the thing a team discovers at the worst possible moment. Under version rotation, the previous tileset is still sitting in object storage, still warm at the edge, still correct. Rolling back is rewriting one URL in the style document and waiting out its sixty-second TTL. Nothing is rebuilt, nothing is purged, and no reader sees a partially warmed cache.
Under in-place replacement with purging, the same rollback means re-uploading the previous archive over the current one and purging again — a second stampede on top of the first, with the map broken for as long as the upload takes. The two approaches cost the same to build. They differ entirely in what a bad deploy costs, which is the only moment either of them is being judged.
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.
FAQ
Do I need a tile server at all?
Only if tiles are generated on demand or updated individually. A static tileset on object storage with a CDN in front has no process on the read path, which is one fewer thing to scale and to page someone about.
What is the single most important header?
Cache-Control. Without it the edge applies its own short default, and the origin ends up serving traffic proportional to readers rather than to publishes.
Why does a deploy spike origin traffic?
Because purging invalidates every edge copy at once and every subsequent request misses simultaneously. Rotating a version prefix avoids the state entirely — nothing is invalidated, so nothing stampedes.
How many requests does one viewport cost?
Twelve to sixteen tiles on a desktop, six to nine on a phone, times the number of sources. Reducing sources pays more than reducing layers, because round trips dominate on mobile.
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.