Cloudflare Workers Edge Routing for Vector Tile Endpoints

A Cloudflare Worker that parses /tiles/{z}/{x}/{y}.mvt, checks the Cache API, fetches from the FastAPI origin on a miss, stores the tile with Cache-Control, and rejects invalid tile coordinates before any origin round-trip.

← Back to Edge Routing & Tile Delivery at Scale

Put a Cloudflare Worker in front of your FastAPI ST_AsMVT origin so tile coordinate validation, cache lookups, and CORS all happen in the edge PoP — and an invalid or already-cached request never costs a database round-trip.

Context & when to use

A plain origin-pull CDN forwards every cache miss to your origin verbatim: it cannot reject a malformed z=30 request, cannot normalise a fragmenting query string out of the cache key, and cannot add CORS headers your FastAPI app forgot. A Cloudflare Worker runs your own JavaScript at every PoP before the cache is consulted, which lets you do all three at the edge. This is the edge-compute row of the decision matrix in Edge Routing & Tile Delivery at Scale — reach for it when you need logic in front of the cache, not just caching.

Prefer a Worker over a bare CDN when you want to validate tile coordinates before they reach the origin, strip a signed token/exp pair out of the cache key so per-user tokens do not shatter the hit ratio, or serve map clients on other origins that need Access-Control-Allow-Origin. If none of those apply and your origin already sends correct Cache-Control, a plain origin-pull CDN is simpler and you do not need this page.

The Worker below is JavaScript because it runs on Cloudflare’s V8 edge runtime, not on your Python origin. Your ST_AsMVT tile generation stays in FastAPI exactly as described in Tile Generation & CDN Distribution; the Worker only routes and caches in front of it.

Preconditions: a deployed FastAPI tile origin reachable from Cloudflare, a Worker route bound to your tile hostname (tiles.example.com/*), and wrangler for local development and deploy.


Request flow

Cloudflare Worker vector tile routingA map client requests a tile from the Worker. The Worker validates z, x and y coordinate bounds, returning 400 if invalid. It builds a normalised cache key and probes the Cache API. On a hit it returns the cached tile. On a miss it fetches from the FastAPI origin, attaches Cache-Control, stores the response asynchronously with waitUntil, and returns it with CORS headers.Map ClientMapLibre GLCloudflare Worker (per PoP)1 · validate z/x/yout of range → 4002 · Cache API probekey: normalised URL4 · cache.put()via ctx.waitUntilCache API is per-coloFastAPI OriginST_AsMVT · PostGISGET3 · miss → fetch origintile bytesHIT → cached bytes + CORS

Runnable implementation

The Worker parses the path, validates coordinates, probes the per-colo Cache API, and only on a miss touches the origin. Deploy with wrangler deploy.

// src/worker.js — Cloudflare Worker: vector tile edge router
// Route binding (wrangler.toml): routes = ["tiles.example.com/tiles/*"]

const MAX_ZOOM = 15;
const ORIGIN = "https://origin.internal.example.com"; // FastAPI ST_AsMVT origin
const ALLOWED_ORIGINS = new Set([
  "https://maps.example.com",
  "https://app.example.com",
]);
// Path shape: /tiles/{version}/{layer}/{z}/{x}/{y}.mvt
const TILE_RE = /^\/tiles\/(\d+)\/([a-z0-9_-]+)\/(\d+)\/(\d+)\/(\d+)\.mvt$/;

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);

    // CORS preflight for browser map clients on another origin
    if (request.method === "OPTIONS") {
      return new Response(null, { status: 204, headers: corsHeaders(request) });
    }
    if (request.method !== "GET") {
      return new Response("Method Not Allowed", { status: 405 });
    }

    const match = url.pathname.match(TILE_RE);
    if (!match) {
      return new Response("Not a tile path", { status: 404 });
    }

    const version = Number(match[1]);
    const layer = match[2];
    const z = Number(match[3]);
    const x = Number(match[4]);
    const y = Number(match[5]);

    // 1. Coordinate validation — reject BEFORE any cache or origin work.
    //    An unbounded z or out-of-range x/y is a cache-fill DoS otherwise.
    const dim = 2 ** z;
    if (z < 0 || z > MAX_ZOOM || x < 0 || x >= dim || y < 0 || y >= dim) {
      return new Response("Invalid tile coordinates", { status: 400 });
    }

    // 2. Build a NORMALISED cache key: drop token/exp so per-user signed
    //    URLs do not fragment the cache. The version segment is already in
    //    the path, so it is part of the key for free.
    const token = url.searchParams.get("token") ?? "";
    const exp = url.searchParams.get("exp") ?? "";
    const cacheKeyUrl = new URL(url.origin + url.pathname); // no query string
    const cacheKey = new Request(cacheKeyUrl.toString(), { method: "GET" });
    const cache = caches.default;

    // 3. Probe the per-colo Cache API
    let response = await cache.match(cacheKey);
    if (response) {
      response = new Response(response.body, response);
      response.headers.set("x-edge-cache", "HIT");
      applyCors(response, request);
      return response;
    }

    // 4. MISS — fetch from the FastAPI origin, forwarding auth for verification.
    //    The origin verifies token/exp; the Worker only strips them from the KEY.
    const originUrl = new URL(ORIGIN + url.pathname);
    if (token) originUrl.searchParams.set("token", token);
    if (exp) originUrl.searchParams.set("exp", exp);

    const originResp = await fetch(originUrl.toString(), {
      cf: { cacheTtl: 0 }, // we manage caching explicitly via the Cache API
      headers: { "accept": "application/vnd.mapbox-vector-tile" },
    });

    if (originResp.status === 403) {
      return new Response("Forbidden", { status: 403, headers: corsHeaders(request) });
    }

    // 5. Attach immutable Cache-Control (version is in the path) and store.
    response = new Response(originResp.body, originResp);
    response.headers.set(
      "Cache-Control",
      "public, max-age=31536000, s-maxage=31536000, immutable",
    );
    response.headers.set("Content-Type", "application/vnd.mapbox-vector-tile");
    response.headers.set("x-edge-cache", "MISS");

    // Only cache successful, non-empty-error responses
    if (originResp.status === 200) {
      // waitUntil lets cache.put() finish AFTER the response is sent to the client
      ctx.waitUntil(cache.put(cacheKey, response.clone()));
    }

    applyCors(response, request);
    return response;
  },
};

function corsHeaders(request) {
  const origin = request.headers.get("Origin");
  const allow = origin && ALLOWED_ORIGINS.has(origin) ? origin : "null";
  return {
    "Access-Control-Allow-Origin": allow,
    "Access-Control-Allow-Methods": "GET, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type",
    "Vary": "Origin",
  };
}

function applyCors(response, request) {
  for (const [k, v] of Object.entries(corsHeaders(request))) {
    response.headers.set(k, v);
  }
}

Key parameters & options

ParameterPurposeRecommended value
MAX_ZOOMUpper bound on z; anything higher is rejected with 400Match the origin’s max zoom (12–15 typical)
Cache-Control max-age / s-maxageTTL for the stored tile in browser and edge31536000 for versioned immutable tiles
immutable directiveTells the browser never to revalidate within max-ageAlways, for versioned tile paths
cf: { cacheTtl: 0 } on origin fetchDisable Cloudflare’s implicit fetch cache so the Cache API is the only cache0 — one cache layer, not two
ctx.waitUntil(cache.put(...))Store the tile without delaying the client responseAlways wrap cache.put
caches.default vs Cache API caches.open()Default cache is shared; named caches isolate namespacescaches.default for tiles
Workers KVCross-colo shared store for the current data version or a signing keyRead version from KV, cache in-Worker per request
ALLOWED_ORIGINSExplicit allowlist for Access-Control-Allow-OriginNever reflect arbitrary Origin with credentials

The Cache API is per-colo: a tile cached in Frankfurt is not visible in Singapore, so the first request in each region is a miss. This is expected and fine for tiles — each PoP warms independently and the origin sees at most one miss per region per version. If you need cross-colo coordination (for example, a globally consistent data version), read it from Workers KV, which replicates to every PoP within seconds.


Gotchas & failure modes

  • Cannot perform I/O on behalf of a different request (cache.put after response returned). Calling await cache.put(...) inline can outlive the request scope if you have already returned. Always wrap it in ctx.waitUntil(cache.put(cacheKey, response.clone())) so the runtime keeps the store alive after the client response is flushed — and clone() the response, because a body stream can only be read once.

  • Cache API is per-colo, so hit ratios look low in tests. Curling from one machine hits one PoP and looks great; a global audience spreads across ~300 colos, each starting cold. This is not a bug. Do not “fix” it by forcing everything to KV — KV is a key-value store with its own limits, not a tile cache. Let each colo warm from the origin.

  • Signed token/exp fragments the cache key. If you build the cache key from the full URL including the query string, every user’s unique token creates a unique entry and the hit ratio collapses to zero. Strip token/exp from the cache key (as above) while still forwarding them to the origin for verification.

  • Invalid coordinates reach the origin. Forgetting the z/x/y bounds check lets z=30 through to the origin, which computes a pointless ST_AsMVT and caches a never-reused tile. Validate against 2 ** z first; return 400 before any fetch or cache.match.

  • Missing CORS makes tiles fail silently in the browser. A cross-origin MapLibre client that receives a tile without Access-Control-Allow-Origin sees an opaque network error with no useful message. Apply CORS on both the hit and miss paths, and handle the OPTIONS preflight.

  • Caching a 403 or 500. Only cache.put on status === 200. Caching an auth failure or origin error freezes a bad response into the edge for a year under an immutable directive.


Verification

Deploy, then confirm the miss-then-hit transition and the coordinate guard:

# First request in a colo: MISS
curl -sI "https://tiles.example.com/tiles/42/roads/10/512/340.mvt?token=abc&exp=1799999999" \
  | grep -iE 'x-edge-cache|cf-cache-status|cache-control'
# x-edge-cache: MISS

# Second identical request from the same region: HIT
curl -sI "https://tiles.example.com/tiles/42/roads/10/512/340.mvt?token=abc&exp=1799999999" \
  | grep -iE 'x-edge-cache|cf-cache-status'
# x-edge-cache: HIT
# cf-cache-status: HIT

# Out-of-range zoom is rejected at the edge, never reaching the origin:
curl -sI "https://tiles.example.com/tiles/42/roads/30/9/9.mvt" | head -1
# HTTP/2 400

# CORS preflight from an allowed map origin:
curl -sI -X OPTIONS "https://tiles.example.com/tiles/42/roads/10/512/340.mvt" \
  -H "Origin: https://maps.example.com" \
  -H "Access-Control-Request-Method: GET" | grep -i access-control-allow-origin
# access-control-allow-origin: https://maps.example.com

A MISS that never becomes a HIT on repeat means the cache key still contains the query string (fragmenting on token), or the origin sent an uncacheable Cache-Control.


← Back to Edge Routing & Tile Delivery at Scale