Serving PMTiles Through a Cloudflare Worker
A PMTiles archive on R2 is already readable by a browser through the protocol handler, so a Worker is not required. It becomes worth adding when you need something the bucket cannot provide: conventional z/x/y URLs for clients that do not speak PMTiles, headers the bucket will not set, or an access decision made per request.
When to Use This
Three concrete reasons, and one non-reason.
A client that cannot read PMTiles. Desktop GIS, a server-side renderer, an older MapLibre build. The Worker translates z/x/y into a range read and returns the tile.
Headers the bucket cannot express. Per-path cache policies, CORS that varies by origin, a Content-Encoding the object metadata got wrong.
An access decision. Signed URLs, a referer rule, or a rate limit that has to run before the read.
The non-reason is performance. A Worker cannot beat the CDN serving a cached byte range directly, and adding one to a working PMTiles deployment adds a hop for nothing.
Production Command
// wrangler.toml
// [[r2_buckets]]
// binding = "TILES"
// bucket_name = "tiles"
import { PMTiles, Source } from "pmtiles";
class R2Source {
constructor(bucket, key) {
this.bucket = bucket;
this.key = key;
}
getKey() {
return this.key;
}
async getBytes(offset, length) {
const object = await this.bucket.get(this.key, {
range: { offset, length },
});
if (!object) throw new Error(`missing object ${this.key}`);
return { data: await object.arrayBuffer() };
}
}
const TILE_PATH = /^\/tiles\/(\d+)\/(\d+)\/(\d+)\.mvt$/;
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const match = TILE_PATH.exec(url.pathname);
if (!match) return new Response("Not found", { status: 404 });
const [, z, x, y] = match.map(Number);
// Serve from the Worker cache first — this is what keeps R2 reads rare.
const cache = caches.default;
const cached = await cache.match(request);
if (cached) return cached;
const archive = new PMTiles(new R2Source(env.TILES, "v43/basemap.pmtiles"));
const tile = await archive.getZxy(z, x, y);
if (!tile) {
// An absent tile is normal outside the tileset's coverage.
return new Response(null, { status: 204 });
}
const response = new Response(tile.data, {
headers: {
"Content-Type": "application/vnd.mapbox-vector-tile",
"Content-Encoding": "gzip", // tiles are stored gzipped in the archive
"Cache-Control": "public, max-age=31536000, immutable",
"Access-Control-Allow-Origin": "*",
},
});
ctx.waitUntil(cache.put(request, response.clone()));
return response;
},
};
Three lines carry most of the value. The caches.default lookup means a warm Worker never touches R2, which is what keeps both latency and R2 operation counts low. The 204 for a missing tile is friendlier than a 404 for a client that pans past the tileset’s edge — either is defensible, but pick one and be consistent so monitoring can distinguish normal from broken. And the Content-Encoding: gzip must match how the archive stores tiles, or the client receives compressed bytes it will not decompress.
Interaction Effects
With the directory. The Worker resolves the header and root directory on a cold start, which is two extra R2 reads before its first tile. Caching those in the Worker’s cache — the pmtiles library does this within a request, but not across them — is worth doing explicitly for a high-traffic deployment.
With R2 operation costs. R2 charges per class-B operation, so an uncached Worker performing three reads per tile is measurably more expensive than one serving from cache. This is the practical argument for the caches.default lookup, not just latency.
With CORS. A Worker sets its own CORS headers, which supersedes the bucket policy for anything it serves. That is often the reason to add one — see CORS configuration.
With versioned prefixes. Hard-coding the archive key in the Worker means a version rotation requires a deploy. Reading the key from an environment variable, or from the request path, keeps publishing independent of code deploys.
Performance Impact
| Path | Latency added | R2 reads |
|---|---|---|
| Worker cache hit | ~1 ms | 0 |
| Worker miss, directory cached | ~15 ms | 1 |
| Worker cold start, cold directory | ~45 ms | 3 |
| Direct PMTiles, CDN hit | 0 ms | 0 |
The last row is the comparison that matters. A direct deployment with a warm edge is strictly faster than any Worker path, which is why the Worker should be justified by capability rather than speed.
Common Mistakes
Setting Content-Encoding that does not match the archive. Produces either a client that cannot parse the tile or a double-decompression error.
Not caching in the Worker. Every tile becomes an R2 read, which costs both latency and operation charges.
Returning 404 for tiles outside coverage. Defensible, but it makes normal panning indistinguishable from breakage in the logs. 204 is clearer if the monitoring is set up for it.
Hard-coding the archive key. Couples publishing to code deploys for no benefit.
FAQ
Does the Worker need to parse the whole directory?
No. The pmtiles library fetches only the header and the directory entries it needs, using ranged reads against R2 — the same mechanism a browser client uses.
Is this cheaper than a tile server?
Considerably. There is no process to keep running, and the cost is per request with the great majority served from cache. It is more expensive than serving the archive directly, which costs nothing but bandwidth.
Can one Worker serve several archives?
Yes — derive the archive key from the request path. That also solves the versioning problem, since the prefix becomes part of the URL rather than part of the code.
What about range requests through the Worker?
The Worker exposes z/x/y, so clients do not issue ranges at all. If a client wants to read the archive directly, point it at the bucket URL and bypass the Worker entirely.
Related
- PMTiles Range-Request Delivery — the parent topic and the direct-delivery path this compares against.
- Configuring R2 and S3 for PMTiles Range Requests — the bucket configuration underneath.
- PMTiles Directory Structure and Leaf Lookups — what the Worker is resolving per request.
- Rate Limiting Public Tile Endpoints — a common reason to put a Worker in the path at all.