Debugging HTTP 416 Range-Request Failures for PMTiles

An HTTP 416 Range Not Satisfiable means the byte range the client asked for cannot be served — almost always because the requested range starts at or beyond the end of the object, or because an intermediary ignored the Range header and the client then re-asked for a range past what it thinks the file size is. The fix is never guesswork: three curl probes tell you exactly which hop failed and why. This page is the decision tree for 416, silent whole-file 200 fallbacks, and CORS-blocked reads.

When to Use This — Symptoms

Reach for this when a PMTiles map that worked in one environment fails in another, or right after a re-upload or CDN change. The tell-tale symptoms:

  • The map hangs on load and the network tab shows a single huge request (whole-file 200 fallback).
  • A tile request returns 416 and MapLibre logs a tile fetch error.
  • The console shows a CORS error and no HTTP status is even reachable.
  • Tiles loaded yesterday and stopped after a rebuild or a compression setting change.

The working baseline you are debugging against is described in the parent PMTiles range-request delivery section: the first request must be a 206, not a 200.

Specification: 206 vs 200 vs 416

Status Meaning What it tells you
206 Partial Content Range honoured Working correctly; Content-Range confirms the slice
200 OK Range ignored Server or proxy served the whole object; ranges not honoured on this hop
416 Range Not Satisfiable Range invalid Requested start ≥ object size, or a malformed/unsatisfiable range

The relevant headers form a tight loop:

Header Direction Role
Range: bytes=start-end request What the client asks for
Accept-Ranges: bytes response Origin advertises range support
Content-Range: bytes start-end/total 206 response Confirms the returned slice and the true object size
Content-Length response On a 206, the slice length; on a 200, the whole object

The decisive number is the /total in Content-Range. When a 416 fires, the origin usually still returns Content-Range: bytes */<total>, telling you the real object size — compare that to the range the client requested and the mismatch is immediately obvious.

Production Command Block

Start with the header range every PMTiles client reads first, and classify the status.

bash
URL="https://tiles.example.com/v3a1b2c3/roads.pmtiles"

# 1. Probe the first-read range (header + root directory)
curl -sI -H "Range: bytes=0-16383" "$URL" \
  | grep -iE "http/|accept-ranges|content-range|content-length"
# 206 + Content-Range: bytes 0-16383/<total>  -> ranges work
# 200 + full Content-Length                   -> range ignored on some hop
# 416 + Content-Range: bytes */<total>        -> object smaller than requested range

If you get a 206, validate that the bytes are actually a PMTiles v3 archive and not an error page served with a 206:

bash
# 2. Fetch the first 7 bytes and confirm the PMTiles v3 magic string
curl -s -r 0-6 "$URL" | xxd
# Expect ASCII "PMTiles" -> 50 4d 54 69 6c 65 73
# Anything else (e.g. 3c 21 44 4f = "<!DO") means an HTML error body, not the archive

If the first probe returned 416, read the true size and compare it to what you uploaded:

bash
# 3. Ask for the size the origin actually holds
curl -sI -H "Range: bytes=0-0" "$URL" | grep -i content-range
# Content-Range: bytes 0-0/12582912  -> object is 12 MB; if your build is 40 MB, the upload truncated
ls -l roads.pmtiles   # compare against the local build size

A one-line guard you can drop into a deploy script to fail fast when the published archive is not range-serving a valid header:

bash
check_pmtiles() {
  local url="$1"
  local resp; resp=$(curl -s -w '%{http_code}' -r 0-6 -o /tmp/head.bin "$url")
  [ "$resp" = "206" ] || { echo "FAIL: expected 206, got $resp"; return 1; }
  head -c 7 /tmp/head.bin | grep -q "PMTiles" || { echo "FAIL: bad magic bytes"; return 1; }
  echo "OK: $url serves a valid ranged PMTiles header"
}
check_pmtiles "$URL"

Interaction Effects

A 416 rarely originates at the object store alone. A CDN that strips or rewrites Range on a cache miss will fetch the whole object and cache a 200; the browser, having earlier learned a larger size, then requests a range past the cached body’s end and gets a 416 — the failure surfaces two hops away from its cause. A stale or truncated upload (an interrupted multipart put) produces a smaller object than the client’s cached directory expects, so ranges valid against the old size become unsatisfiable. And a Content-Length mismatch introduced by mid-path compression shifts every byte offset, so a range that was correct at the origin lands in the wrong place after the edge — the same double-compression trap that also corrupts tile bodies. The bucket-side settings that prevent most of this are in configuring R2 and S3 for PMTiles range requests.

Performance Impact

The quietest failure is the most expensive. A silent 200 full-file fetch does not error — the map eventually renders — but it downloads the entire archive on first load instead of the ~16 KB header read plus a handful of small tile ranges. For a 40 MB continental basemap that is the difference between a sub-second first paint and a multi-second stall on every cold client, and on S3 it bills the full object transfer per visitor. Because it produces no console error, it is easy to ship to production unnoticed; the check_pmtiles guard above exists precisely to catch a 200-where-a-206-belongs in CI before it reaches users.

Common Mistakes

CDN compression changing the length so ranges misalign. An intermediary that gzips the already-gzipped archive changes Content-Length, so byte offsets computed from the PMTiles directory no longer match the transformed body and reads land wrong or return 416. Disable automatic compression on .pmtiles paths; the tile blobs inside are already gzipped. Confirm with curl -sI "$URL" | grep -i content-encoding — it should be empty.

Truncated multipart upload. An interrupted aws s3 cp of a large archive can leave a smaller object than intended. Every range past the real end returns 416. Re-upload and verify the byte count matches the local file with the Content-Range: bytes 0-0/<total> probe above.

Requesting a range beyond a re-uploaded smaller file. Replacing an archive in place with a smaller rebuild under the same URL means clients (and CDN caches) holding the old directory request offsets that no longer exist. This is exactly why archives should be published under a new versioned prefix per build rather than overwritten — the cache-headers and versioning approach sidesteps it entirely.

Missing Accept-Ranges producing a whole-file 200. If the origin never advertises Accept-Ranges: bytes, the client stops trying ranges and issues a plain GET. R2 and S3 send it by default, so its absence points at a proxy or Worker that buffered and re-emitted the body. To confirm the archive itself is intact while you chase the hop, inspect it locally with the PMTiles metadata CLI tools.


Parent: PMTiles Range-Request Delivery