All articles
cloudflare

Workers Cache on an authenticated tile API: the split-entrypoint pattern

How to enable Cloudflare Workers Cache on an API that must authenticate and meter every request, using an uncached gateway entrypoint in front of a cached origin entrypoint. Warm viewport hit rate went from 10% to 100% and median warm tile latency dropped from 874ms to under 100ms.

Vinayak Kulkarni
4 min read

Cloudflare Workers Cache has one property that makes it dangerous for any authenticated API: a cache HIT skips your Worker entirely. No code runs. For a public marketing page that is the whole point. For a usage-metered vector-tile API it is a billing hole — anyone could hammer a cached tile URL forever and you would never count a single request.

This post is the pattern that gets you both: every request authenticated, quota-checked, and metered, while the expensive part (R2 reads, PMTiles directory traversal, tile extraction) is served from Cloudflare's cache. Real numbers at the end: warm-viewport cache hits went from ~10% to 100%, and median warm tile latency dropped from 874ms to under 100ms.

Why you cannot just enable the cache

The naive setup is one block in wrangler.json:

{
  "cache": { "enabled": true }
}

With Cache-Control headers on your responses, repeated requests now serve from cache without invoking the Worker. Two problems for an authenticated API:

  1. Cache HITs skip auth and metering. The Worker never runs, so nothing validates the API key, nothing checks the quota, nothing increments usage. Cached responses become free and unauthenticated.
  2. The query string is part of the cache key. Tile URLs carry ?key=<api-key>, so every customer's identical tile is a separate cache entry. Your hit rate fragments by the number of API keys.

The split-entrypoint pattern

Workers Cache is configurable per entrypoint (Wrangler 4.107.0+). That is the escape hatch. You split the Worker in two:

  • The default entrypoint is the gateway: cache disabled. It runs on every request — auth, origin/scope checks, quota enforcement, usage metering.
  • A named Origin entrypoint is the dumb data reader: cache enabled. It only fetches tile bytes from R2 and sets Cache-Control.

The gateway calls the origin over the ctx.exports loopback, and the cache sits between them:

{
  "cache": { "enabled": true },
  "exports": {
    "default": { "type": "worker", "cache": { "enabled": false } },
    "Origin": { "type": "worker", "cache": { "enabled": true } }
  }
}
import { WorkerEntrypoint } from 'cloudflare:workers';

export class Origin extends WorkerEntrypoint<Env> {
  async fetch(request: Request): Promise<Response> {
    const tile = await readTileFromR2(this.env, new URL(request.url).pathname);
    if (!tile) return new Response(null, { status: 204 });
    return new Response(tile.data, {
      headers: {
        'Content-Type': 'application/x-protobuf',
        'Cache-Control': 'public, max-age=604800',
        'Cache-Tag': `archive:${tile.archive}`,
      },
    });
  }
}
// Default entrypoint — runs on EVERY request, HIT or MISS
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    const token = await validateApiKey(request, env); // 401/403 on failure
    await enforceQuota(env, token); // 429 when exhausted

    // Strip ?key= so every customer shares one cache entry per tile
    const clean = new URL(request.url);
    clean.search = '';

    const origin = await ctx.exports.Origin.fetch(new Request(clean));
    const hit = origin.headers.get('cf-cache-status') === 'HIT';

    recordUsage(env, ctx, token, hit); // metered on HIT and MISS alike

    return withCorsAndCacheStatus(origin, hit);
  },
};

The invariant this buys you: a client hammering the same cached tile a million times causes a million auth checks, a million quota checks, and a million usage increments — and roughly one R2 read. Cache hits stay billable. The cf-cache-status header on the loopback response even tells the gateway whether it was a HIT, so your analytics can track hit rate per key.

Three details worth stealing:

  • Cache-Tag on origin responses enables ctx.cache.purge({ tags: ['archive:planet'] }) — re-upload a tile archive, purge only its tiles.
  • Range requests are handled for you. Workers Cache strips Range before invoking the entrypoint, caches the full body, and slices the 206 itself. Your origin should return plain 200s and never implement range logic.
  • Quota rejection happens before the loopback, so over-quota traffic never even consults the cache.

If you deploy through Nuxt/Nitro rather than hand-written Workers, the cache and exports blocks pass straight through nitro.cloudflare.wrangler — I covered that passthrough in an earlier TIL. The extra step for Nitro is wrapping the generated entry so the gateway logic owns /v1/* routes at the top level; a small build module that rewrites Nitro's virtual cloudflare-server-entry does it in ~90 lines.

The measurement trap

My first deployment of this pattern looked broken: a console.log inside the cached entrypoint fired on every request, so I concluded the cache never engaged and spent days chasing framework bugs that did not exist.

Do not measure a cached entrypoint by whether its logs fire — background waitUntil() work and launch-week rollout noise both poison that signal. The reliable probe is to call the cached entrypoint twice with the same URL from inside the Worker and read cf-cache-status off the loopback responses:

const r1 = await ctx.exports.Origin.fetch(new Request(url));
await new Promise((r) => setTimeout(r, 1500));
const r2 = await ctx.exports.Origin.fetch(new Request(url));
// healthy: r1 MISS, r2 HIT, and Cache-Tag stripped from r2

If Cache-Tag leaks through on the second response, the response never transited the cache — that header is stripped by the cache layer on the way out.

The numbers

Methodology: identical k6 suites (single-user smoke; 10 VU × 30s baseline; a 20-tile z10 viewport fetched with 20-way parallelism, twice) against the same PMTiles archive on R2. BEFORE is the old hand-rolled caches.default implementation, which had no request collapsing and racy async cache writes. AFTER is the split-entrypoint Workers Cache deployment — measured on a freshly deployed Worker with a fully cold cache, which if anything handicaps the AFTER numbers.

MetricBefore (caches.default)After (Workers Cache)
Warm 20-tile viewport hit rate~10% (18/20 re-MISSED)100% (20/20 HIT)
Warm viewport avg tile latencyre-fetches at ~750ms70ms
k6 smoke median (single user, warm)874ms~98ms
k6 baseline median (10 VU / 30s)686ms377ms
k6 baseline p9545.7s12.1s
Errors under load0%0%

The headline is the viewport row. A map view loads 16–24 tiles at once; the old implementation re-missed 18 of 20 tiles three seconds after first load because parallel requests raced past each other into R2 and the async cache writes mostly had not landed. Workers Cache does its own request collapsing, so the second viewport load is 20 HITs at a median of 70ms — the difference between a map that stutters on every pan and one that feels instant.

The p95 numbers stay ugly in both columns for the same reason: a cold z10 tile over a 232 GiB planet archive is a genuine R2 range-read plus PMTiles directory walk, and no request-path cache fixes a cold miss. That is what tiered caching upstream and archive-level prewarming are for — a different post.

Median warm-tile latency dropped roughly (874ms → 98ms) and warm-viewport hit rate went from 10% to 100%, while every single request — hit or miss — still passed auth, quota, and usage metering. That is the whole trick: cache the bytes, never the gate.

cloudflareworkerscachepmtilesnitrogis