PMTiles Range-Request Delivery from Object Storage
A .pmtiles archive is designed to be served as a single static object, with the client reading only the few kilobytes it needs for any given tile through HTTP range requests. That removes the tile server entirely — no Martin process, no tileserver-gl, no PostGIS connection pool — but it shifts the correctness burden onto three response headers and a CORS policy that object stores do not send by default. This guide covers how the archive layout turns into byte-range reads, and the exact Cloudflare R2 and Amazon S3 configuration that makes those reads work instead of collapsing into a whole-file download.
This is the static half of Tile Serving & CDN Delivery; the dynamic alternative — running a real server against an MBTiles file — is covered under tile server selection.
Prerequisites
| Requirement | Minimum | Notes |
|---|---|---|
A built .pmtiles archive |
v3 spec | Produced by pmtiles convert or written directly by Tippecanoe |
| Object store | R2, S3, or B2 | Any store that honours Range requests and returns 206 |
| CDN in front | forwards Range |
Must pass the Range header through and cache per-range or per-object |
pmtiles CLI |
1.19+ | convert, show, verify subcommands used below |
| MapLibre GL JS | 3.0+ | Consumes the pmtiles:// protocol handler |
pmtiles JS protocol |
3.0+ | pmtiles npm package that registers the protocol with MapLibre |
The archive format itself is documented in the PMTiles specification deep dive; this section assumes you already have a valid v3 archive and focuses only on getting it served over HTTP. If you are unsure what your archive contains, inspect it first with the PMTiles metadata CLI tools.
Core Concept: How PMTiles Becomes HTTP Range Reads
A v3 archive is a single file with four regions laid out in order: a fixed 127-byte header, a root directory, a run of leaf directories, and the tile data section holding the gzipped MVT blobs. The header records the byte offset and length of every other region, and each directory entry maps a tile ID (a Hilbert-curve index of z/x/y) to a byte offset and length inside the tile-data section.
The client never downloads the whole file. Instead it performs a small, bounded sequence of range reads:
- First read — header + root directory. The pmtiles JS protocol issues one
Range: bytes=0-16383request that pulls the 127-byte header plus the root directory in a single round trip. From the header it learns where the leaf directories and tile data begin. This read happens once per archive and is cached for the session. - Second read — leaf directory. For the requested tile ID, the root directory either resolves the tile directly (small archives) or points to a leaf directory. If a leaf is needed, one more range read fetches that leaf directory entry.
- Third read — the tile. The resolved directory entry gives an exact
offsetandlengthin the tile-data section, and the client issuesRange: bytes=offset-(offset+length-1)to fetch just that one gzipped tile.
So a cold tile costs at most three requests, and every subsequent tile that lands in an already-fetched leaf directory costs a single range read. The response headers that make this work — or silently break it — are:
| Header | Set on | Purpose | If wrong |
|---|---|---|---|
Accept-Ranges: bytes |
origin response | Advertises that ranges are honoured | Client downloads the whole archive |
Content-Range: bytes …/N |
206 response |
Confirms the returned slice and total size | JS cannot validate the read; range logic breaks |
ETag |
origin response | Stable identity for revalidation and range consistency | Cross-range mismatches after a re-upload |
Access-Control-Allow-Origin |
origin response | Permits the cross-origin fetch | Every request blocked by the browser |
Access-Control-Expose-Headers |
origin response | Lets JS read Content-Range/Content-Length |
pmtiles.js cannot read range metadata |
Cache-Control |
origin response | Edge and browser caching policy | Re-fetch on every load, or stale tiles pinned |
The first request MapLibre makes is a 206 Partial Content, not a 200. If you ever see a 200 OK with the full Content-Length of the archive on that first request, range serving is not working and the client has fallen back to a full download — the single most common failure in this whole stage.
Step-by-Step Implementation
Step 1 — Convert MBTiles to PMTiles
Tippecanoe can emit .pmtiles directly, but most existing builds are .mbtiles. Convert once with the CLI, which clusters the tile data and writes the directory structure described above.
# Convert an existing MBTiles build to a clustered v3 archive
pmtiles convert roads.mbtiles roads.pmtiles
# Confirm it is a valid, clustered archive before uploading
pmtiles verify roads.pmtiles
pmtiles show roads.pmtiles | head -20
Verify: pmtiles verify exits 0 and pmtiles show prints the tile type (mvt), the zoom range, and a non-zero tile count. A non-clustered archive still works but performs more leaf reads — reconvert with a recent CLI if verify warns about clustering.
Step 2 — Upload with the correct content-type and cache-control
The archive is opaque binary, so it takes an octet-stream content-type, and because you will publish each rebuild under a new versioned prefix, the object itself is immutable.
# Cloudflare R2 (via wrangler)
wrangler r2 object put "tiles/v3a1b2c3/roads.pmtiles" \
--file roads.pmtiles \
--content-type application/octet-stream \
--cache-control "public, max-age=31536000, immutable"
# Amazon S3 (equivalent)
aws s3api put-object \
--bucket tiles.example.com \
--key v3a1b2c3/roads.pmtiles \
--body roads.pmtiles \
--content-type application/octet-stream \
--cache-control "public, max-age=31536000, immutable"
The v3a1b2c3 path segment is a schema-hash prefix. Publishing under a new prefix on every rebuild is what lets a deploy skip a global cache purge, and it is the same mechanism the cache headers and versioning section applies to /{z}/{x}/{y}.pbf tile paths. The exact bucket flags for both providers are detailed in configuring R2 and S3 for PMTiles range requests.
Verify: a HEAD on the uploaded object returns your Cache-Control and Content-Type unchanged.
curl -sI "https://tiles.example.com/v3a1b2c3/roads.pmtiles" \
| grep -iE "content-type|cache-control|accept-ranges"
Step 3 — Enable CORS and range support on the bucket/CDN
R2 and S3 both honour Range on GET by default and will return 206 Partial Content — the work is CORS. A browser reading byte ranges through fetch() must be allowed to send the Range request header and must be allowed to read the Content-Range and Content-Length response headers, which requires an explicit Access-Control-Expose-Headers list.
[
{
"AllowedOrigins": ["https://maps.example.com"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range", "If-Match"],
"ExposeHeaders": ["Content-Range", "Content-Length", "ETag", "Accept-Ranges"],
"MaxAgeSeconds": 3600
}
]
# Apply the CORS policy (S3)
aws s3api put-bucket-cors \
--bucket tiles.example.com \
--cors-configuration file://cors.json
# R2 accepts the same rule shape via wrangler or the dashboard
wrangler r2 bucket cors put tiles --file cors.json
Verify: a preflight-style probe shows the exposed headers on the actual 206 response.
curl -s -o /dev/null -D - \
-H "Origin: https://maps.example.com" \
-H "Range: bytes=0-16383" \
"https://tiles.example.com/v3a1b2c3/roads.pmtiles" \
| grep -iE "http/|content-range|access-control-(allow-origin|expose)"
# Expect: HTTP/2 206, Content-Range: bytes 0-16383/<total>,
# Access-Control-Allow-Origin and Access-Control-Expose-Headers present
Step 4 — Wire the pmtiles:// protocol in MapLibre
The pmtiles JS package registers a protocol handler that translates MapLibre’s tile requests into the range reads described above. Point the style source at the archive with a pmtiles:// URL.
import maplibregl from "maplibre-gl";
import * as pmtiles from "pmtiles";
// Register the protocol once, before creating any map
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
const map = new maplibregl.Map({
container: "map",
style: {
version: 8,
sources: {
roads: {
type: "vector",
url: "pmtiles://https://tiles.example.com/v3a1b2c3/roads.pmtiles",
attribution: "© OpenStreetMap contributors"
}
},
layers: [
{
id: "roads-line",
type: "line",
source: "roads",
"source-layer": "roads",
paint: { "line-color": "#555", "line-width": 1 }
}
]
}
});
Verify: open the browser network tab, filter to the archive name, and confirm the first request is 206 with a small Content-Length (16 KB, not the archive size), followed by more small 206 responses as you pan and zoom. The full style-side wiring — where sources.url lives and how multiple archives combine — is covered under Map Styling & Layer Synchronization.
Optimization Knobs
| Knob | Conservative | Aggressive | Trade-off |
|---|---|---|---|
| Initial range size | 16 KB (0-16383) |
64 KB+ | Larger first read caches more of the root directory but wastes bytes on small archives |
| Root vs leaf split | small root, many leaves | large root, few leaves | A bigger root directory resolves more tiles in the first read but inflates that read |
| HTTP version | HTTP/1.1 | HTTP/2 or /3 | Multiplexing amortises the 2–3 round trips of a cold tile into one connection |
| Directory caching | per-request | session cache | pmtiles.js caches directories in memory; keep the map instance alive to avoid re-reading them |
| R2 vs S3 billing | S3 range-billed | R2 zero-egress | R2 charges no egress; S3 bills each range GET and its transfer |
| Edge cache granularity | cache whole object | cache per-range | Caching individual ranges at the edge cuts origin range GETs for hot tiles |
The single largest win is HTTP/2 at the CDN: the header, leaf, and tile reads of a cold tile collapse into one connection instead of three TCP handshakes. The second is keeping the root and leaf directories warm in the client — the pmtiles JS protocol caches them per map instance, so tearing down and recreating the map on every route change re-pays the first read every time.
Integration with Adjacent Pipeline Stages
Range-request delivery sits between the container build and the renderer. Upstream, the .pmtiles archive is the direct output of pmtiles convert on a Tippecanoe build, so the PMTiles specification governs the directory layout that determines how many leaf reads a tile costs. Downstream, the archive’s public URL feeds straight into a MapLibre style’s sources.url as a pmtiles:// value, which is where style structuring picks it up.
Versioning ties the two ends together. Because each rebuild is uploaded under a fresh schema-hash prefix, the immutable Cache-Control from the cache headers and versioning section is safe to apply to the archive: the object at a given URL never changes, so a one-year max-age, immutable value can never serve a stale tile. Rotating the prefix and flipping the style pointer replaces a global purge entirely.
Troubleshooting
1. First request is a 200 with the full archive size
Symptom: the network tab shows one request that downloads hundreds of megabytes; the map hangs on load.
Diagnosis:
curl -sI "https://tiles.example.com/v3a1b2c3/roads.pmtiles" | grep -i accept-ranges
Fix: the origin is not advertising Accept-Ranges: bytes, usually because a proxy or Worker in front rewrote the response. R2 and S3 send it by default on GET, so the culprit is almost always the CDN or a custom Worker buffering the whole object. Ensure Range is forwarded and the response is streamed, not buffered.
2. CORS blocks the range read
Symptom: console shows Access to fetch … blocked by CORS policy or Cannot read Content-Range, and no tiles render.
Fix: the bucket has no CORS rule, or the rule is missing Range in AllowedHeaders or Content-Range in ExposeHeaders. Re-apply the policy from Step 3. Missing ExposeHeaders is the subtle case: the fetch succeeds but pmtiles.js cannot read the range metadata, so the map fails silently.
3. HTTP 416 Range Not Satisfiable
Symptom: a range read returns 416 and MapLibre logs a tile fetch error.
Diagnosis:
curl -sI -H "Range: bytes=0-16383" "https://tiles.example.com/v3a1b2c3/roads.pmtiles" \
| grep -iE "http/|content-range"
Fix: the requested range starts past the end of the object, which almost always means the uploaded file is truncated or smaller than the client expects (a re-upload of a smaller archive under a reused URL). The full decision tree is in debugging HTTP 416 range-request failures.
4. Tiles decode as garbage (double-gzip)
Symptom: 206 responses arrive with the right byte counts, but MapLibre throws a protobuf parse error.
Diagnosis: the CDN applied its own gzip on top of the already-gzipped MVT blob inside the archive.
curl -sI "https://tiles.example.com/v3a1b2c3/roads.pmtiles" | grep -i content-encoding
Fix: disable the CDN’s automatic compression for .pmtiles paths. The tile blobs are already gzipped inside the archive; a second Content-Encoding: gzip layer double-compresses the byte range and corrupts the read.
Further Reading
Configuring R2 and S3 for PMTiles Range Requests — the exact bucket settings for both providers: content-type, cache-control, the CORS JSON applied via put-bucket-cors, and how to confirm Accept-Ranges so clients read byte ranges instead of the whole file.
Debugging HTTP 416 Range-Request Failures for PMTiles — a decision tree for 416 Range Not Satisfiable, whole-file 200 fallbacks, and CORS-blocked reads, with curl commands and the fixes for R2, S3, and CDNs that strip or rewrite the Range header.
Parent: Tile Serving & CDN Delivery
Related
- CDN Cache Headers & Versioned Tile URLs — the
Cache-Control,immutable, and version-prefix rules that make an immutable.pmtilesobject safe to pin for a year. - Tile Server Selection: Martin, tileserver-gl & pg_tileserv — the dynamic alternative to static PMTiles hosting, for tilesets that change often or are generated per-request from PostGIS.
- PMTiles Specification Deep Dive — the header, root, and leaf-directory layout that determines how many range reads each tile costs.