Caching at the Edge: Beyond Traditional CDN

A traditional CDN caches static assets following standard HTTP headers. Cloudflare Workers takes the concept much further: with the Cache API, your code TypeScript controls with surgical precision what is cached, for how long, how it is invalidated and what fallback logic to apply when the cache is stale.

The result is one Programmable CDN: instead of configuring rules static in a dashboard, write business logic that decides on the fly whether a response is cacheable, with which TTL and with which cache key. This flexibility it is especially valuable for REST APIs, custom pages, and semi-dynamic content.

What You Will Learn

  • How the Cloudflare Workers Cache API works and how it differs from the Browser Cache API
  • Caching strategies: Cache-First, Network-First, Stale-While-Revalidate
  • Custom cache keys: isolate cache by user, language, version
  • Invalidation for URLs, tags and prefixes with the Cloudflare Zone Purge API
  • Vary headers: differentiated cache for Accept-Encoding, Accept-Language
  • Advanced patterns: cache warming, grace period and circuit breaker with KV
  • Common mistakes and how to avoid them in production

The Cache API: Fundamentals and Differences from the Browser

The Cache API exposed in Workers follows the same interface as the Service Worker Cache API browser, but with important differences. In Workers, the cache is distributed on all Cloudflare PoPs: When a Worker in Frankfurt caches a response, that answer is not automatically available in London or Amsterdam. Every PoP has the own local cache.

// Accesso alla cache nel Worker
// In Workers esiste un unico "default" cache namespace
const cache = caches.default;

// Oppure cache named (isolata per nome, utile per namespace logici)
const apiCache = await caches.open('api-v2');

// Le operazioni fondamentali:
// cache.match(request) -> Response | undefined
// cache.put(request, response) -> void
// cache.delete(request) -> boolean

Cache API: Important Constraints

  • HTTP requests only (not arbitrary URLs like in the browser)
  • It is not possible to cache replies with Vary: *
  • The cache is per-PoP: there is no automatic cross-datacenter invalidation with cache.delete()
  • Responses with status 206 (Partial Content) are not cacheable
  • The maximum TTL respected is 31 days

Strategy 1: Cache-First with Fallback Network

The most common strategy for APIs with rarely changing data: serve from cache when available, otherwise go to the source and populate the cache.

// worker.ts - Cache-First Strategy

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // Solo richieste GET sono cacheable
    if (request.method !== 'GET') {
      return fetch(request);
    }

    const cache = caches.default;

    // 1. Controlla la cache
    let response = await cache.match(request);

    if (response) {
      // Cache HIT: aggiungi header diagnostico e restituisci
      const headers = new Headers(response.headers);
      headers.set('X-Cache-Status', 'HIT');
      return new Response(response.body, {
        status: response.status,
        headers,
      });
    }

    // 2. Cache MISS: fetch dall'origin
    response = await fetch(request);

    // 3. Clona la risposta (il body e un ReadableStream, consumabile una sola volta)
    const responseToCache = response.clone();

    // 4. Metti in cache con ctx.waitUntil() per non bloccare la risposta al client
    ctx.waitUntil(
      cache.put(request, responseToCache)
    );

    // 5. Restituisci la risposta originale con header diagnostico
    const headers = new Headers(response.headers);
    headers.set('X-Cache-Status', 'MISS');
    return new Response(response.body, {
      status: response.status,
      headers,
    });
  },
};

interface Env {}

Strategy 2: Stale-While-Revalidate

The strategy stale-while-revalidate it is the one that gives the best compromise between data freshness and perceived speed: the client receives Always an immediate response from the cache, even if it is running in the background the Worker updates the cache for the next request.

// Stale-While-Revalidate implementato manualmente
// (Cloudflare supporta anche il header standard, ma questa versione offre più controllo)

const CACHE_TTL = 60;        // Secondi prima che la cache sia "fresh"
const STALE_TTL = 300;       // Secondi aggiuntivi in cui la cache e "stale but usable"

interface CacheMetadata {
  cachedAt: number;
  ttl: number;
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    if (request.method !== 'GET') return fetch(request);

    const cache = caches.default;

    // Crea una Request con una custom cache key che include i metadata
    const cacheKey = new Request(request.url, {
      headers: request.headers,
    });

    const cached = await cache.match(cacheKey);

    if (cached) {
      const cachedAt = parseInt(cached.headers.get('X-Cached-At') ?? '0');
      const age = (Date.now() - cachedAt) / 1000;

      if (age < CACHE_TTL) {
        // FRESH: servi dalla cache senza revalidazione
        return addCacheHeaders(cached, 'FRESH', age);
      }

      if (age < CACHE_TTL + STALE_TTL) {
        // STALE: servi dalla cache ma revalida in background
        ctx.waitUntil(revalidate(cacheKey, cache));
        return addCacheHeaders(cached, 'STALE', age);
      }
    }

    // MISS o troppo vecchio: fetch sincrono
    return fetchAndCache(cacheKey, cache, ctx);
  },
};

async function revalidate(cacheKey: Request, cache: Cache): Promise<void> {
  const fresh = await fetch(cacheKey.url);
  if (fresh.ok) {
    const toCache = addTimestamp(fresh);
    await cache.put(cacheKey, toCache);
  }
}

async function fetchAndCache(
  cacheKey: Request,
  cache: Cache,
  ctx: ExecutionContext
): Promise<Response> {
  const response = await fetch(cacheKey.url);

  if (response.ok) {
    const toCache = addTimestamp(response.clone());
    ctx.waitUntil(cache.put(cacheKey, toCache));
  }

  const headers = new Headers(response.headers);
  headers.set('X-Cache-Status', 'MISS');
  return new Response(response.body, { status: response.status, headers });
}

function addTimestamp(response: Response): Response {
  const headers = new Headers(response.headers);
  headers.set('X-Cached-At', String(Date.now()));
  // Cache-Control: max-age elevato per far "sopravvivere" la risposta in cache
  headers.set('Cache-Control', 'public, max-age=86400');
  return new Response(response.body, { status: response.status, headers });
}

function addCacheHeaders(response: Response, status: string, age: number): Response {
  const headers = new Headers(response.headers);
  headers.set('X-Cache-Status', status);
  headers.set('Age', String(Math.floor(age)));
  return new Response(response.body, { status: response.status, headers });
}

interface Env {}

Custom Cache Keys: Isolate Cache by Context

By default, the cache key is the full URL of the request. But often you need of caches separated by language, API version, user or device tier. The solution is build one custom cache key as an object Request with a short URL.

// Cache differenziata per lingua e versione API

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const cache = caches.default;
    const url = new URL(request.url);

    // Estrai parametri rilevanti per la cache key
    const lang = request.headers.get('Accept-Language')?.split(',')[0]?.split('-')[0] ?? 'en';
    const apiVersion = url.searchParams.get('v') ?? 'v1';
    const tier = request.headers.get('X-User-Tier') ?? 'free';

    // Costruisci una URL sintetica come cache key
    // Non deve essere una URL reale, solo identificativa
    const cacheKeyUrl = new URL(request.url);
    cacheKeyUrl.searchParams.set('_ck_lang', lang);
    cacheKeyUrl.searchParams.set('_ck_v', apiVersion);
    // Non includiamo 'tier' nella key se vuoi condividere la cache tra tier

    const cacheKey = new Request(cacheKeyUrl.toString(), {
      method: 'GET',
      // Importante: non copiare headers di autenticazione nella cache key
      // altrimenti ogni utente avrebbe la propria cache entry
    });

    // Cerca nella cache con la custom key
    let response = await cache.match(cacheKey);

    if (response) {
      return response;
    }

    // Fetch dall'origin passando la richiesta originale (con auth headers)
    const originResponse = await fetch(request);

    if (originResponse.ok && isCacheable(originResponse)) {
      const toCache = setCacheHeaders(originResponse.clone(), 300);
      ctx.waitUntil(cache.put(cacheKey, toCache));
    }

    return originResponse;
  },
};

function isCacheable(response: Response): boolean {
  // Non mettere in cache risposte con dati personali o Set-Cookie
  if (response.headers.has('Set-Cookie')) return false;
  if (response.headers.get('Cache-Control')?.includes('private')) return false;
  if (response.headers.get('Cache-Control')?.includes('no-store')) return false;
  return true;
}

function setCacheHeaders(response: Response, maxAge: number): Response {
  const headers = new Headers(response.headers);
  headers.set('Cache-Control', `public, max-age=${maxAge}, s-maxage=${maxAge}`);
  // Rimuovi header che potrebbero impedire il caching
  headers.delete('Set-Cookie');
  return new Response(response.body, { status: response.status, headers });
}

interface Env {}

Cache Headers: s-maxage, stale-while-revalidate, stale-if-error

Cloudflare respects the Standard Cache-Control headers and extends it the meaning. Understanding these guidelines is essential:

Directive Meaning Example
max-age=N TTL for browser and CDN (N seconds) max-age=300
s-maxage=N TTL for CDN/proxy only (overrides max-age for Cloudflare) s-maxage=3600, max-age=60
stale-while-revalidate=N Additional seconds in which to serve stale while it revalidates s-maxage=60, stale-while-revalidate=300
stale-if-error=N Seconds in which to serve stale if the origin returns error stale-if-error=86400
no-store Do not cache under any circumstances For sensitive data
private Cache browser only, not CDN For authenticated responses
// Esempio: API con s-maxage e stale-while-revalidate via header

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // Routing con TTL differenziati per tipo di risorsa
    if (url.pathname.startsWith('/api/products')) {
      return fetchWithCacheHeaders(request, {
        sMaxAge: 300,           // 5 minuti fresh in CDN
        staleWhileRevalidate: 3600, // 1 ora stale accettabile
        staleIfError: 86400,    // 1 giorno stale in caso di errore origin
      });
    }

    if (url.pathname.startsWith('/api/user')) {
      // Dati utente: non cacheare in CDN
      return fetchWithCacheHeaders(request, {
        sMaxAge: 0,
        private: true,
      });
    }

    if (url.pathname.startsWith('/static')) {
      // Asset statici: cache aggressiva
      return fetchWithCacheHeaders(request, {
        sMaxAge: 31536000, // 1 anno
        immutable: true,
      });
    }

    return fetch(request);
  },
};

interface CacheOptions {
  sMaxAge?: number;
  staleWhileRevalidate?: number;
  staleIfError?: number;
  private?: boolean;
  immutable?: boolean;
}

async function fetchWithCacheHeaders(
  request: Request,
  options: CacheOptions
): Promise<Response> {
  const response = await fetch(request);
  const headers = new Headers(response.headers);

  let cacheControl = '';

  if (options.private) {
    cacheControl = 'private, no-store';
  } else {
    const parts: string[] = ['public'];
    if (options.sMaxAge !== undefined) parts.push(`s-maxage=${options.sMaxAge}`);
    if (options.staleWhileRevalidate) parts.push(`stale-while-revalidate=${options.staleWhileRevalidate}`);
    if (options.staleIfError) parts.push(`stale-if-error=${options.staleIfError}`);
    if (options.immutable) parts.push('immutable');
    cacheControl = parts.join(', ');
  }

  headers.set('Cache-Control', cacheControl);
  return new Response(response.body, { status: response.status, headers });
}

interface Env {}

Invalidation: Purge for URL, Tag and Prefix

cache.delete(request) delete the cache only in the local PoP where the Worker runs. To invalidate the cache across all PoPs globally, you have to use the API Cloudflare Zone Purge REST. This is the correct mechanism for content management and deploy.

// Invalidation globale tramite Cloudflare API
// Da usare tipicamente da un webhook CMS o da un Worker admin

interface PurgeOptions {
  files?: string[];      // URL specifici
  tags?: string[];       // Cache-Tag headers
  prefixes?: string[];   // URL prefix
  hosts?: string[];      // Tutti gli URL di un host
}

async function purgeCloudflareCache(
  zoneId: string,
  apiToken: string,
  options: PurgeOptions
): Promise<void> {
  const response = await fetch(
    `https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${apiToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(options),
    }
  );

  if (!response.ok) {
    const error = await response.json();
    throw new Error(`Purge failed: ${JSON.stringify(error)}`);
  }
}

// Worker che funge da webhook per invalidazione CMS
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== 'POST') {
      return new Response('Method Not Allowed', { status: 405 });
    }

    // Verifica il secret del webhook
    const secret = request.headers.get('X-Webhook-Secret');
    if (secret !== env.WEBHOOK_SECRET) {
      return new Response('Unauthorized', { status: 401 });
    }

    const body = await request.json() as WebhookPayload;

    // Invalida le URL specifiche aggiornate dal CMS
    if (body.type === 'post.updated') {
      await purgeCloudflareCache(env.ZONE_ID, env.CF_API_TOKEN, {
        files: [
          `https://example.com/blog/${body.slug}`,
          `https://example.com/api/posts/${body.id}`,
          `https://example.com/sitemap.xml`,
        ],
      });
    }

    // Invalida per tag (richiede Cache-Tag header sulle risposte origin)
    if (body.type === 'category.updated') {
      await purgeCloudflareCache(env.ZONE_ID, env.CF_API_TOKEN, {
        tags: [`category-${body.categorySlug}`],
      });
    }

    return new Response(JSON.stringify({ purged: true }), {
      headers: { 'Content-Type': 'application/json' },
    });
  },
};

interface WebhookPayload {
  type: string;
  id?: string;
  slug?: string;
  categorySlug?: string;
}

interface Env {
  ZONE_ID: string;
  CF_API_TOKEN: string;
  WEBHOOK_SECRET: string;
}

Cache Tags: Semantic Invalidation

I Cache Tags they are the most powerful mechanism for invalidation selective. They work by adding a header Cache-Tag to the answers: each response can have multiple tags, and you can invalidate all URLs associated with a tag with a single API call.

// Origin server o Worker che aggiunge Cache-Tag alle risposte

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const response = await fetch(request);
    const headers = new Headers(response.headers);

    // Aggiungi tag semantici basati sul contenuto
    const tags: string[] = [];

    // Tag per tipo di risorsa
    if (url.pathname.startsWith('/api/products')) {
      const productId = url.pathname.split('/')[3];
      tags.push('products');                    // Invalida tutti i prodotti
      if (productId) tags.push(`product-${productId}`); // Invalida questo prodotto specifico
    }

    if (url.pathname.startsWith('/api/categories')) {
      const catId = url.pathname.split('/')[3];
      tags.push('categories');
      if (catId) tags.push(`category-${catId}`);
    }

    // Tag per versione dell'API
    const apiVersion = url.pathname.split('/')[2];
    if (apiVersion?.startsWith('v')) {
      tags.push(`api-${apiVersion}`);
    }

    if (tags.length > 0) {
      // Cache-Tag: lista separata da virgole, max 16KB
      headers.set('Cache-Tag', tags.join(','));
    }

    return new Response(response.body, { status: response.status, headers });
  },
};

// Esempio di invalidazione per tag dopo un aggiornamento:
// POST /api/admin/purge
// { "tags": ["product-123", "categories"] }
// Invalida tutte le URL che hanno Cache-Tag: product-123 o categories

interface Env {}

Advanced Pattern: Cache with KV as L2

The Cache API has an important limitation: it is not programmatically accessible for arbitrary read/write, HTTP requests only. For more patterns complex (such as coordinated invalidation, circuit breaker, or object cache non-HTTP), use Workers KV as L2 cache.

// Cache a due livelli: Cache API (L1, HTTP) + KV (L2, programmabile)

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    const cacheKey = buildCacheKey(url);

    // L1: Cache API (piu veloce, locale al PoP)
    const l1Cache = caches.default;
    const l1Hit = await l1Cache.match(request);

    if (l1Hit) {
      return addHeader(l1Hit, 'X-Cache', 'L1-HIT');
    }

    // L2: KV Store (globale, programmabile, con TTL gestito da KV)
    const kvCached = await env.API_CACHE.getWithMetadata<CacheMetadata>(cacheKey);

    if (kvCached.value !== null) {
      const { value, metadata } = kvCached;

      // Ricostruisci una Response dalla stringa KV
      const cachedResponse = new Response(value, {
        headers: {
          'Content-Type': metadata?.contentType ?? 'application/json',
          'Cache-Control': 'public, max-age=60',
          'X-Cache': 'L2-HIT',
          'X-Cached-At': String(metadata?.cachedAt ?? 0),
        },
      });

      // Popola anche L1 per richieste successive nello stesso PoP
      ctx.waitUntil(l1Cache.put(request, cachedResponse.clone()));

      return cachedResponse;
    }

    // MISS su entrambi i livelli: fetch dall'origin
    const originResponse = await fetch(request);

    if (originResponse.ok) {
      const body = await originResponse.text();
      const contentType = originResponse.headers.get('Content-Type') ?? 'application/json';

      const metadata: CacheMetadata = {
        cachedAt: Date.now(),
        contentType,
        url: url.toString(),
      };

      // Salva in KV con TTL di 5 minuti
      ctx.waitUntil(
        env.API_CACHE.put(cacheKey, body, {
          expirationTtl: 300,
          metadata,
        })
      );

      // Salva anche in L1
      const toL1 = new Response(body, {
        headers: {
          'Content-Type': contentType,
          'Cache-Control': 'public, max-age=60',
          'X-Cache': 'MISS',
        },
      });
      ctx.waitUntil(l1Cache.put(request, toL1));

      return new Response(body, {
        headers: {
          'Content-Type': contentType,
          'X-Cache': 'MISS',
        },
      });
    }

    return addHeader(originResponse, 'X-Cache', 'BYPASS-ERROR');
  },
};

function buildCacheKey(url: URL): string {
  // Normalizza l'URL per la cache key (rimuovi query params non semantici)
  const params = new URLSearchParams(url.searchParams);
  params.delete('utm_source');
  params.delete('utm_medium');
  params.delete('_t'); // timestamp di cache-busting
  params.sort(); // ordine deterministico
  return `${url.pathname}?${params.toString()}`;
}

function addHeader(response: Response, key: string, value: string): Response {
  const headers = new Headers(response.headers);
  headers.set(key, value);
  return new Response(response.body, { status: response.status, headers });
}

interface CacheMetadata {
  cachedAt: number;
  contentType: string;
  url: string;
}

interface Env {
  API_CACHE: KVNamespace;
}

Cache Warming: Pre-Populate the Cache

Il cache warming it is the practice of pre-populating the cache first that real requests arrive, eliminating the cold cache problem after deployment massive invalidations. It is implemented with a Worker scheduled via Cron Trigger.

// wrangler.toml - Cron Trigger per cache warming
// [triggers]
// crons = ["*/15 * * * *"]  # Ogni 15 minuti

// worker.ts - Cache Warming Worker

const URLS_TO_WARM = [
  'https://api.example.com/products?featured=true',
  'https://api.example.com/categories',
  'https://api.example.com/homepage',
  'https://api.example.com/navigation',
];

export default {
  // Scheduled handler per Cron Trigger
  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
    console.log(`Cache warming started at ${new Date(event.scheduledTime).toISOString()}`);

    const results = await Promise.allSettled(
      URLS_TO_WARM.map(url => warmUrl(url))
    );

    const succeeded = results.filter(r => r.status === 'fulfilled').length;
    const failed = results.filter(r => r.status === 'rejected').length;

    console.log(`Cache warming complete: ${succeeded} success, ${failed} failed`);
  },

  async fetch(request: Request): Promise<Response> {
    return new Response('Cache Warmer Worker', { status: 200 });
  },
};

async function warmUrl(url: string): Promise<void> {
  // Forza bypass della cache aggiungendo header speciale
  // (da gestire lato Worker principale con whitelist IP o secret)
  const response = await fetch(url, {
    headers: {
      'Cache-Control': 'no-cache', // Forza revalidazione
      'X-Cache-Warm': 'true',
    },
  });

  if (!response.ok) {
    throw new Error(`Failed to warm ${url}: ${response.status}`);
  }
}

interface Env {}
interface ScheduledEvent {
  scheduledTime: number;
  cron: string;
}

Cache Debugging and Monitoring

Cloudflare exposes diagnostic headers in responses to understand cache status. The most important is CF-Cache-Status:

CF-Cache-Status Meaning
HIT Served from Cloudflare cache
MISS Not cached, requested at origin
EXPIRED Cached but TTL expired, request to origin
STALE Served stale (stale-while-revalidate)
BYPASS Cache bypassed (Cookie, Auth header, etc.)
DYNAMIC Non-cacheable (dynamic response)
REVALIDATED Cache validated with origin (304 Not Modified)
// Worker che logga le metriche di cache su KV Analytics
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const response = await fetch(request);

    const cacheStatus = response.headers.get('CF-Cache-Status') ?? 'UNKNOWN';

    // Logga la metrica in background
    ctx.waitUntil(
      logCacheMetric(env, {
        url: request.url,
        status: cacheStatus,
        timestamp: Date.now(),
        country: request.cf?.country ?? 'unknown',
        datacenter: request.cf?.colo ?? 'unknown',
      })
    );

    return response;
  },
};

async function logCacheMetric(env: Env, metric: CacheMetric): Promise<void> {
  // Aggrega per finestre di 1 minuto
  const minuteKey = `metrics:${Math.floor(metric.timestamp / 60000)}:${metric.status}`;
  const current = parseInt(await env.METRICS_KV.get(minuteKey) ?? '0');
  await env.METRICS_KV.put(minuteKey, String(current + 1), { expirationTtl: 3600 });
}

interface CacheMetric {
  url: string;
  status: string;
  timestamp: number;
  country: string;
  datacenter: string;
}

interface Env {
  METRICS_KV: KVNamespace;
}

Conclusions and Next Steps

The Cloudflare Workers Cache API transforms the CDN from a passive tool to a component active in architecture. With the strategies described in this article you can build a granular, semantic, invalidable caching layer that reduces load 70-90% on origin for typical public API workloads.

The key points to remember: use ctx.waitUntil() so as not to block the response to the client, build custom cache keys to isolate different contexts, use Cache Tags for semantic invalidation, and combine Cache API with KV for more complex patterns.

Next Articles in the Series

  • Article 9: Testing of Workers in Local — Miniflare, Vitest e Wrangler Dev: how to write unit tests and integration tests for Workers without deployment.
  • Article 10: Full-Stack Architectures at the Edge — Case Study by Zero to Production: A complete REST API with Workers + D1 + R2 and CI/CD on GitHub Actions.