Serving Vector Tiles from Object Storage with Range Requests

A tile archive is readable from a browser when four things are true: the bucket answers Range with a 206, the CORS policy allows the reading origin and exposes Content-Range, the object is served without transport compression, and the CDN in front of it caches byte ranges rather than passing them through. Miss any one and the failure is silent — a blank map, a console error, or a bill that is an order of magnitude larger than expected. This page is the delivery-configuration companion to PMTiles and cloud-native tile archives.

Quick Reference

Setting Value Why it matters Primary Use Case
Accept-Ranges bytes The client will not attempt partial reads without it Every archive read from a browser
Access-Control-Allow-Origin your map’s origin Browsers withhold the body from scripts otherwise Cross-origin tile hosting
Access-Control-Expose-Headers Content-Range, Content-Length The reader verifies the range was honoured Every archive read from a browser
Cache-Control public, max-age=31536000, immutable Removes revalidation from every tile read Content-addressed archive keys
Content-Encoding absent Transport compression invalidates byte offsets Archives with per-tile compression
CDN range caching enabled Stops full-object origin fetches on partial reads Any archive behind a CDN

What a Tile Read Looks Like on the Wire

A tile fetch is one GET with a Range: bytes=start-end header. The bucket replies 206 Partial Content with a Content-Range describing what it actually sent. That is the entire protocol — and each of the four requirements above is about keeping that exchange intact from the bucket, through the edge, to the JavaScript that has to read the bytes.

The CORS requirement is the one that surprises people, because it has two halves. The first half is ordinary: Access-Control-Allow-Origin must permit the page’s origin, or the browser blocks the response outright. The second half is specific to range reads: the PMTiles reader inspects Content-Range on the response to confirm the server honoured the request rather than returning the whole object, and a browser hides all non-simple response headers from JavaScript unless they are named in Access-Control-Expose-Headers. A configuration that satisfies the first half and not the second produces a request that succeeds at the network layer and fails inside the library.

The headers a tile range read depends on at each hop A browser issues a GET with a Range header and an Origin header. The CDN edge must forward the Range rather than stripping it, and must cache by range rather than passing through. Object storage answers with a 206 status, a Content-Range header, an Access-Control-Allow-Origin header, and an Access-Control-Expose-Headers header naming Content-Range. On the way back the edge must preserve all four. The reader in the browser then checks Content-Range before decoding the tile, which is why exposing that header is not optional. Browser GET /archive.pmtiles Range: bytes=… Origin: https://map… reader checks Content-Range CDN edge forwards Range caches by byte segment no body transformation must not strip CORS headers Object storage 206 Partial Content Content-Range: … Allow-Origin + Expose no Content-Encoding request on miss Every header below has to survive all three hops a hop that drops one produces a blank map with no network error curl bypasses the same-origin policy entirely, so it cannot detect a missing CORS header. Always verify from a page served on a different origin than the archive. The reader treats a 200 with the whole body as a failure: it asked for a range and did not get one.

Transport compression deserves its own warning. Enabling gzip on the archive object seems harmless — everything else on the site is compressed — but it changes the byte offsets of the response body relative to the stored object. A client asking for bytes 4,096–8,191 of the archive receives bytes 4,096–8,191 of the compressed stream, which is a different and meaningless region. The tiles inside a PMTiles archive are already compressed individually, so there was nothing to gain in the first place.

Configuring the Bucket

The policy below is what actually has to be applied; the specifics differ by provider but the four elements do not.

python
# Requires: boto3>=1.34  (Python 3.10+) — CORS policy for browser range reads
from __future__ import annotations

import json

import boto3
from botocore.exceptions import BotoCoreError, ClientError

TILE_CORS = {
    "CORSRules": [
        {
            "AllowedOrigins": [],           # filled in per deployment
            "AllowedMethods": ["GET", "HEAD"],
            # The browser sends Range as a non-simple header, so it must be
            # allowed on the request side...
            "AllowedHeaders": ["Range", "If-Match", "Origin"],
            # ...and Content-Range must be exposed on the response side, or the
            # reader cannot confirm the server honoured the range.
            "ExposeHeaders": ["Content-Range", "Content-Length", "ETag", "Accept-Ranges"],
            "MaxAgeSeconds": 86400,
        }
    ]
}


def apply_tile_cors(bucket: str, origins: list[str]) -> dict:
    """Publish the CORS policy a browser tile reader requires."""
    if not origins:
        raise ValueError("at least one allowed origin is required")
    if "*" in origins and len(origins) > 1:
        raise ValueError("a wildcard origin cannot be combined with specific ones")

    policy = json.loads(json.dumps(TILE_CORS))    # deep copy
    policy["CORSRules"][0]["AllowedOrigins"] = origins

    client = boto3.client("s3")
    try:
        client.put_bucket_cors(Bucket=bucket, CORSConfiguration=policy)
        return client.get_bucket_cors(Bucket=bucket)
    except (BotoCoreError, ClientError) as exc:
        raise RuntimeError(f"could not apply CORS to {bucket}: {exc}") from exc


def publish_tile_object(bucket: str, key: str, path: str) -> None:
    """Upload with immutable caching and no transport compression."""
    client = boto3.client("s3")
    try:
        client.upload_file(
            path, bucket, key,
            ExtraArgs={
                "ContentType": "application/vnd.pmtiles",
                "CacheControl": "public, max-age=31536000, immutable",
                # Deliberately no ContentEncoding: gzip here would shift every
                # byte offset and silently break range addressing.
            },
        )
    except (BotoCoreError, ClientError) as exc:
        raise RuntimeError(f"upload of {key} failed: {exc}") from exc

Validation

Check the origin first, then the edge, then the browser — in that order, because a failure at one layer masks the next.

bash
# 1. Origin honours ranges and returns the right headers
curl -s -D- -o /dev/null -H 'Range: bytes=0-16383' -H 'Origin: https://map.example.com' \
     https://storage.example.com/tiles/parcels.9f3c1a20.pmtiles
# Expect: HTTP/2 206
#         content-range: bytes 0-16383/4102938112
#         access-control-allow-origin: https://map.example.com
#         access-control-expose-headers: Content-Range, Content-Length, ETag, Accept-Ranges
#         (no content-encoding line at all)

# 2. The edge caches ranges instead of passing them through
for i in 1 2; do
  curl -s -D- -o /dev/null -H 'Range: bytes=1048576-1064959' \
       https://cdn.example.com/tiles/parcels.9f3c1a20.pmtiles | grep -i 'x-cache\|age:'
done
# Expect: first request a MISS, second a HIT with a non-zero age.

The browser check has to run from a page, because that is the only place the same-origin policy exists. Expected healthy figures: a cold viewport at zoom 14 issues 12–16 requests, of which one is the header-and-root fetch; a warm pan issues only tile reads; and edge hit rate settles above 90% within a few minutes of steady traffic.

What range caching at the edge does to origin traffic Two bars compare origin behaviour for the same one thousand tile reads. Without range caching the edge treats each partial read as a pass-through or fetches the whole multi-gigabyte object, so origin requests track client requests one for one and transferred bytes are enormous. With range caching the edge stores fixed-size segments, so a few dozen segment fetches serve all thousand reads and origin traffic collapses. A note records that the setting is off by default on several providers. 1,000 client tile reads — what reaches the origin Range caching OFF 1,000 origin requests · 640 MB transferred 100% Range caching ON 38 segment fetches · 24 MB transferred 3.8% The setting is off by default on several providers, and nothing about the map looks wrong when it is. The symptom is entirely financial — request charges and egress that scale with readers instead of with content. Why a multi-range request breaks some readers Two request shapes for the same three tiles. Three separate single-range requests each return a plain body with one Content-Range header, which every reader handles. One multi-range request returns a multipart body with per-part headers and a boundary marker, which some content delivery networks rewrite and some readers cannot parse. The symptom is intermittent tile parse errors that vary by edge location. Three single-range requests — always safe 206 · Content-Range · body 206 · Content-Range · body 206 · Content-Range · body One multi-range request — sometimes not 206 · Content-Type: multipart/byteranges; boundary=… part 1 headers · body · boundary · part 2 headers · body · boundary · … Some CDNs rewrite the boundary; some readers cannot parse the parts. If tiles fail intermittently and the failures vary by edge, look for a library coalescing reads into multi-range requests.

Edge Cases and Caveats

A CDN that rewrites bodies. Image optimisation, HTML minification, and “smart compression” features all transform response bodies, and any of them applied to an archive object destroys range addressing. Exclude the archive path from every transformation rule explicitly rather than relying on content-type detection, which frequently misclassifies a custom media type.

Wildcard origins and credentials. Access-Control-Allow-Origin: * is convenient and incompatible with credentialed requests. If tiles are behind a signed URL or a cookie, the policy must name the origin exactly. Note also that signed URLs and immutable caching interact badly: a URL that expires is not immutable, so use origin-level authorisation rather than per-request signatures when you want edge caching to work.

Multi-range requests. The HTTP spec allows a client to ask for several ranges in one request, and the response is a multipart body. Most tile readers do not use this, but some HTTP libraries will attempt it opportunistically — and several CDNs handle it inconsistently. If tiles intermittently fail with parse errors, check whether something is coalescing reads into multi-range requests, and disable that behaviour.

Frequently Asked Questions

Why does my tile archive work in curl but show a blank map in the browser?

Almost certainly CORS. curl ignores the same-origin policy, so it sees a perfectly good 206 response; a browser refuses to hand the body to JavaScript unless the response carries Access-Control-Allow-Origin, and the reader also needs Content-Range exposed via Access-Control-Expose-Headers to confirm the server honoured the range. Missing either produces a silent blank canvas with a console error rather than a visible failure.

Should I compress a tile archive at the HTTP layer?

No. Tile payloads inside the archive are already gzip-compressed individually, so transport compression saves nothing and actively breaks range semantics: a gzipped response body has different byte offsets from the stored object, so a byte range no longer identifies the tile the client asked for. Serve the archive with no Content-Encoding and let the per-tile compression do the work.

How do I stop a CDN from fetching the whole archive on every miss?

Enable range caching, sometimes called partial object or byte-range caching, on the edge configuration. Without it many CDNs treat a range request as an uncacheable pass-through, or worse, fetch the full object from origin to satisfy a 16 KB read. With it, the edge stores fixed-size segments of the object and serves subsequent overlapping ranges from cache, which is what makes tile reads cheap at scale.


← Back to PMTiles and Cloud-Native Tile Archives