Cache API and Invalidation Strategies in Cloudflare Workers
The Cache API in Workers allows granular control of TTL, stale-while-revalidate and invalidation by key: learn how to build a high-performance global distributed caching layer.
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.







