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.

What the Worker does per tile requestA z/x/y request reaches the Worker, which resolves the tile id through the cached directory, issues a ranged read against R2, and returns the tile with its own headers.ClientWorkerR2GET /tiles/14/8188/5449.mvtresolve z/x/y to a tile idranged read: header + directorycached after the first requestranged read: tile offset+lengthtile bytes200 + Cache-Control + CORS
The header and directory reads are cached in the Worker's own cache, so a warm Worker makes one R2 read per tile — and a warm CDN makes none at all.

Production Command

javascript
// 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.

Direct PMTiles against a Worker in frontTwo panels comparing serving a PMTiles archive directly to the browser with putting a Worker in front to expose z/x/y URLs.TWO DEPLOYMENTSDirect from R2No code to deploy or maintainClient resolves the directory itselfCDN caches byte rangesRequires a PMTiles-aware clientSimplest, and the right defaultBehind a WorkerConventional z/x/y URLsHeaders and access decided per requestDirectory cached in the WorkerOne more thing that can failRight when compatibility or control isneeded
Direct is fewer moving parts and requires a PMTiles-aware client. The Worker buys compatibility and control at the cost of a hop and some code.
Whether a Worker earns its place in the pathFour situations mapped to whether adding a Worker in front of a PMTiles archive is justified.DECIDEWhat can the bucket not do on its own?Client cannot readPMTilesWorker — translate z/x/yinto range readsHeaders must varyby path or originWorker — the policybecomes codeAccess must bedecided per requestWorker — signing, refereror rate limitsOnly fasterdelivery is wantedNo Worker — direct isalready faster

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.