Part of the Tile vs Vector Rendering Strategies guide.
Operative rule: a single-file tile archive is only as good as its CORS configuration — the storage must accept a Range request header and expose Content-Range and Accept-Ranges to the page’s origin, or the archive is unreadable from a browser.
How One File Becomes Millions of Tiles
A single-file archive stores every tile in the pyramid back to back, preceded by a directory that maps each z/x/y address to a byte offset and length. A client reads the header and root directory once — a few kilobytes — and from then on requests exactly the bytes for the tiles in view using HTTP range requests. Large archives use nested directories, so a deep zoom may cost one extra directory fetch before the tile itself.
The practical consequence is that the “tile server” disappears. There is no process to run, scale, patch or pay for while idle; the whole pyramid is one object in the same storage as the rest of the site, and the CDN in front of it caches byte ranges exactly as it caches anything else.
Production-Ready Configuration
The Python side is unchanged: cut tiles as usual, then convert the archive and publish it. What needs care is the storage response headers.
from __future__ import annotations
import subprocess
from pathlib import Path
def build_archive(geojson: Path, out: Path, min_zoom: int = 4,
max_zoom: int = 14) -> Path:
"""Cut tiles, then convert to a single-file archive for range serving."""
mbtiles = out.with_suffix(".mbtiles")
subprocess.run(
["tippecanoe", "-o", str(mbtiles), "--force",
"-Z", str(min_zoom), "-z", str(max_zoom),
"--drop-densest-as-needed", "--generate-ids",
str(geojson)],
check=True,
)
subprocess.run(["pmtiles", "convert", str(mbtiles), str(out)], check=True)
return out
UPLOAD_HEADERS = {
# Immutable because the filename carries a content hash.
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Type": "application/octet-stream",
}
CORS_RULES = [
{
"AllowedOrigins": ["https://dashboard.example.org"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range", "If-Match", "If-None-Match"],
# Without these three exposed, the browser fetches but the client
# library cannot read the response — the classic silent failure.
"ExposeHeaders": ["Content-Range", "Content-Length", "Accept-Ranges", "ETag"],
"MaxAgeSeconds": 3600,
}
]
ExposeHeaders is the line that breaks most first deployments. A cross-origin response’s headers are hidden from JavaScript unless the server names them explicitly, so the range request succeeds at the network level and the library sees a response it cannot interpret. The symptom is a map that loads its style, requests the archive, and then renders nothing at all.
What Replaces the Tile Server
Caching and Versioning the Archive
Because the archive is a single object, cache behaviour is simple and worth getting right on the first deploy. Give the file a content-hashed name and an immutable, year-long cache lifetime; then a rebuild produces a new name and no purge is ever needed. The only object that must revalidate is the small manifest that tells the page which archive is current.
Verification Steps
- Issue a
HEADrequest and confirmAccept-Ranges: bytesis present. - Issue a range request from the browser console on the dashboard’s origin and confirm
Content-Rangeis readable from JavaScript. - Load the map with the network panel filtered to the archive and confirm requests are partial, not a full download.
- Confirm the first paint costs a small number of requests, not one per tile plus one per directory.
- Roll the manifest back to the previous archive and confirm the map returns to it without a rebuild.
Common Errors & Fixes
The map is blank and the archive request returned 200
The whole file was fetched because ranges were refused, or the range headers were not exposed. Check Accept-Ranges and the CORS ExposeHeaders list.
Everything works from the same origin and fails when embedded
The CORS rule names the dashboard origin but not the embedding host. Add every origin that will load the map, and remember an embedded map has its own origin.
The first zoom into a new area is slow
That is the leaf-directory fetch. It is expected and only happens once per branch, but if it is frequent the archive’s directory layout can be tuned when it is generated.
The map shows old data after a rebuild
The manifest is being cached. It is the one object that must revalidate; everything else is immutable by name.
Access Control Without a Server
The obvious question about serving a whole pyramid as one public object is what to do when the data is not public. Removing the tile server removes the natural place to check who is asking, and the answer has to come from the storage layer instead.
Three patterns cover almost every case. A signed URL with a short expiry works when the page itself is already behind authentication: the application issues a signed link for the archive when it renders the dashboard, and the browser’s range requests carry the signature. The trade is that the signature has a lifetime, so a session longer than the expiry needs the page to refresh it — which is a small amount of code but must be written before the first reader hits the boundary.
A signed cookie works better for long sessions, because the browser attaches it to every request automatically, including range requests, without the page having to manage anything. It requires that the storage or the CDN in front of it can validate cookies, which not every provider supports.
The third pattern is a thin authorising proxy that validates a session and streams the range through. It reintroduces a process — the thing this whole approach removed — but a proxy that only checks a token and forwards a byte range is a far smaller thing to operate than a tile server, and it can be a small edge function rather than a fleet.
What does not work is obscurity. An archive at an unguessable path is public to anyone who has ever been sent the link, and links travel: into chat histories, into browser sync, into screenshots. If the data genuinely requires access control, it needs one of the three mechanisms above rather than a long random filename.
It is worth deciding this before the first deployment rather than after, because the choice affects the URL structure the style document references, and changing it later means regenerating every style that points at the archive.
Gotchas & Edge Cases
- Some CDNs cache a full-object response and then answer subsequent range requests from it, which works but transfers the whole archive once per edge — check the first request’s size, not just the later ones.
- A
HEADrequest that returns noContent-Lengthbreaks clients that size their first range from it; confirm the header survives the proxy in front of storage. - Compression at the edge can defeat range requests entirely, because the byte offsets no longer refer to the stored bytes. Serve the archive uncompressed; its contents are already compressed.
- Very large archives use nested directories, so the first zoom into a new branch costs an extra request — expected behaviour, not a fault, but worth knowing when reading a waterfall.
- A partially uploaded archive is still readable at its header and will render some tiles and not others; verify the object’s size after upload before switching the pointer to it.
Related
- Tile vs Vector Rendering Strategies — the parent guide covering both pipelines
- Generating Vector Tiles from PostGIS with Tippecanoe — producing the pyramid this archive wraps
- Versioning Tile URLs with Content Hashes for Cache-Busting — the naming scheme that makes purging unnecessary
- Self-Hosting a Basemap Style with MapLibre and PMTiles — serving the basemap the same way