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.
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.
# 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.
# 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.
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.
Related
- PMTiles and Cloud-Native Tile Archives — parent guide: archive anatomy and the cost model this configuration protects
- PMTiles vs MBTiles for Cloud Tile Serving — why the archive can be read without a process at all
- Object Storage Egress Cost Modelling for Geospatial — putting a number on what range caching saves
- Optimizing GeoJSON Payloads for APIs — the same transport concerns applied to feature APIs
← Back to PMTiles and Cloud-Native Tile Archives