PMTiles vs MBTiles for CDN Delivery

For static, read-only tilesets delivered behind a CDN, PMTiles usually wins: it needs no server, resolves each tile with HTTP range requests against a single file, and pairs with zero-egress object storage like Cloudflare R2. MBTiles wins when a tile must be generated or filtered per request — a live PostGIS layer, server-side raster rendering, or on-the-fly attribute selection — because it is a SQLite database that a tile server queries.

When to Use Each

Reach for PMTiles when the tileset is baked ahead of time and the same bytes are served to every user. This covers the overwhelming majority of vector basemaps and overlays: you build once with tippecanoe, upload one .pmtiles file, and the client reads it directly. There is no process to run, patch, autoscale, or page someone about at 3 a.m. This is the model detailed in PMTiles range-request delivery from object storage.

Reach for MBTiles behind a tile server when tiles are not fixed at build time. If you filter features by the authenticated user, join fresh data from PostGIS on each request, render raster tiles server-side, or mutate the tileset in place between requests, you need a running process. A SQLite file sitting in object storage cannot execute a SELECT; something has to open the database and answer z/x/y lookups. Choosing that process is covered in tile server selection.

A useful test: if you could pre-render every tile you will ever serve and the result fits in one file, PMTiles removes the server entirely. If any tile depends on request context, keep the server and let it read MBTiles.

Specification Detail: Head-to-Head

Dimension PMTiles MBTiles
Storage model Single flat binary file, tiles indexed by Hilbert ID in a two-level directory SQLite database; tiles table keyed by zoom_level, tile_column, tile_row
Server required? No — client reads the file over HTTP range requests Yes — martin, tileserver-gl, or similar must open the DB
CDN cache unit Byte ranges of one immutable object (header, directory, tile runs) Each z/x/y response from the tile server
Egress model One object; zero-egress on R2, cheap on S3/GCS Per-tile responses proxied through a server you run and pay for
Redeploy Upload one new file under a new versioned key Replace the DB file and reload the server, or hot-swap the mount
Dynamic / per-request data Not supported — archive is read-only Supported via the server (PostGIS joins, auth filters, raster)
Tooling pmtiles CLI, pmtiles:// protocol, tippecanoe direct output sqlite3, mbutil, tile-join, any tile server
Best fit Static basemaps and overlays on a CDN Dynamic tilesets and server-side rendering

Both formats carry MVT payloads under the same 500 KB per-tile budget, so a build produced for one can be repackaged for the other without regenerating from source. tippecanoe can emit either directly (--output roads.pmtiles or --output roads.mbtiles), and pmtiles convert round-trips an existing MBTiles into PMTiles. The internal layout of the PMTiles archive — the 127-byte header, Hilbert tile IDs, and leaf directories — is documented in the parent PMTiles Specification Deep Dive; the SQLite constraints that make MBTiles awkward for high-concurrency reads are covered under MBTiles Architecture & Limits.

Production Command

Convert an existing MBTiles build to PMTiles, upload it to object storage with an immutable cache header under a versioned prefix, then point the client at it:

bash
# 1. Repackage MBTiles -> single-file PMTiles (no re-tiling from source)
pmtiles convert roads.mbtiles roads.pmtiles

# 2. Confirm the header before shipping
pmtiles show roads.pmtiles
# Expected: version=3, tile_type=mvt, min_zoom / max_zoom as built

# 3. Upload to Cloudflare R2 under a versioned key, immutable cache
wrangler r2 object put tiles/v3/roads.pmtiles \
  --file roads.pmtiles \
  --content-type application/octet-stream \
  --cache-control "public, max-age=31536000, immutable"

# --- Equivalent AWS S3 upload ---
aws s3 cp roads.pmtiles s3://my-tiles/v3/roads.pmtiles \
  --content-type application/octet-stream \
  --cache-control "public, max-age=31536000, immutable"

Client wiring differs by container. PMTiles is served by the file itself over pmtiles://; MBTiles is served by a tile-server template URL:

javascript
// PMTiles: no server, range requests straight to the CDN object
import { Protocol } from "pmtiles";
import maplibregl from "maplibre-gl";
const protocol = new Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile.bind(protocol));

const pmtilesSource = {
  type: "vector",
  url: "pmtiles://https://cdn.example.com/tiles/v3/roads.pmtiles",
};

// MBTiles: a running tile server answers each z/x/y
const mbtilesSource = {
  type: "vector",
  tiles: ["https://tiles.example.com/roads/{z}/{x}/{y}.pbf"],
  minzoom: 0,
  maxzoom: 14,
};

The pmtiles:// prefix tells the protocol handler to fetch the header once, walk the directory, and issue range requests for individual tiles — no origin server is involved beyond the object store. The tile-server URL, by contrast, routes every tile through a process you operate.

Interaction Effects

With versioned prefixes and immutable cache-control. Because a PMTiles archive is one object, you cannot purge a single tile — you rotate the whole file. Uploading under tiles/v3/roads.pmtiles and marking it immutable means edge nodes cache it aggressively and you publish updates by writing tiles/v4/... and swapping the URL in the style. This rotation discipline is the subject of CDN cache headers and versioning, and it applies cleanly to PMTiles precisely because there is exactly one object per version.

With range requests. PMTiles delivery lives or dies on the origin honoring Range: bytes=start-end and returning 206 Partial Content. Object stores support this natively; a misconfigured proxy or WAF that strips range headers forces a full-file download per tile. The mechanics — CORS for Range, Accept-Ranges, and 206 propagation through the edge — are detailed in PMTiles range-request delivery.

With the serving tier. The choice of container is really a choice about whether you operate a serving tier at all. PMTiles pushes serving into the CDN and object store; MBTiles keeps a server in the path so it can do work per request. Both feed the same MapLibre style; only the source declaration changes.

Performance Impact

The trade-off is request shape, not raw tile size. A PMTiles client fetches the 127-byte header once, then the root directory (often under 16 KB), then coalesced byte ranges for the tiles in view — typically a handful of requests to render a viewport, most served from edge cache after warm-up. Range-request overhead is a small constant per request plus one extra round trip on a cold directory; after the directory is cached in the client, tile fetches are single range GETs.

An MBTiles tile server issues one full HTTP response per z/x/y, but every one of those requests traverses a process you run and pay egress on. On R2, PMTiles serves at zero egress cost; on a self-hosted tile server, egress and compute scale with traffic. Edge hit ratio favors PMTiles for static content because the immutable object caches indefinitely, whereas a dynamic tile server’s responses may carry shorter TTLs or vary by request and cache less effectively. For a fixed basemap at global scale, PMTiles typically cuts both operational cost and tail latency; for genuinely dynamic tiles, the server is the price of the capability.

Common Mistakes

Running a heavy tile server for static tiles. Standing up tileserver-gl or martin with autoscaling to serve a basemap that never changes is wasted operational surface. Symptom: a fleet of tile-server pods whose responses are identical for every user and could be one immutable object. Fix: convert once and drop the server.

bash
pmtiles convert basemap.mbtiles basemap.pmtiles
# then serve basemap.pmtiles from R2/S3 with pmtiles:// — delete the server

Expecting dynamic updates from a static PMTiles. A PMTiles archive is read-only; there is no in-place tile update. Symptom: you edit source data and stale tiles keep serving because the old immutable object is still cached. Fix: rebuild, upload under a new versioned key, and rotate the URL — do not try to patch tiles inside the file.

Double gzip. MVT payloads are frequently gzip-compressed inside the container. If the CDN or origin also applies Content-Encoding: gzip to the already-compressed bytes, clients receive doubly-wrapped data and MapLibre fails to parse the tile. Symptom: blank layers with a decode error in the console.

bash
# Verify the object is not re-encoded in transit
curl -sI -H "Accept-Encoding: gzip" \
  https://cdn.example.com/tiles/v3/roads.pmtiles | grep -i content-encoding
# For an .pmtiles object, expect NO extra Content-Encoding from the CDN

Fix: serve the .pmtiles object with Content-Type: application/octet-stream and disable CDN-level compression on it; let the tile’s own tile_compression codec handle decoding client-side.


Parent: PMTiles Specification Deep Dive