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.

What sits between a built archive and a drawn tileA built archive lands in object storage or behind a tile server, a CDN caches what it emits, the browser requests tiles, and MapLibre decodes and draws them.DELIVERY TIERBuilt archive.pmtiles / .mbtilesOriginobject storageor a tile serverCDN edgeCache-Controlrange cachingwhere the trafficshould stopBrowserdecode + draw
Everything on this page is about the middle two boxes. The archive is already built; the question is what answers the request, and how often it has to.

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

What each percentage point of cache hit rate is worthOrigin request counts per ten thousand tile requests at cache hit rates of 80, 95, 99 and 99.9 percent.ORIGIN LOADrequests reaching origin per 10,000 tile requests80% hit rate2,00095% hit rate50099% hit rate10099.9% hit rate10
The relationship is not linear in the way it feels. Moving from 95% to 99% removes four fifths of the origin traffic that remained.
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:

bash
# 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 Vary header, or a Set-Cookie the 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:

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×

How a deploy turns a warm edge into a stampedeA warm cache serving from the edge is invalidated by a deploy, every subsequent request misses and reaches origin simultaneously, and the edge only re-warms as those requests return.CACHE STATESWarmserved at the edgePurgedevery request missesRe-warmingorigin under loadpurge on deployconcurrent misses reachoriginversioned prefixes avoid this path entirely
Rotating the version prefix skips the middle state entirely: the old objects stay warm and readable while the new ones warm up behind a style change.

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.


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