Force a 3600-second CDN edge cache TTL whenever an origin responds
with a Cache-Control: max-age=x header.
Cloudflare's cf.cacheTtl option must be set
before the fetch() call resolves.
But the origin's Cache-Control header is
only readable after the response arrives — you cannot go back and change the
cache instruction retroactively.
Six logical steps executed on every incoming request.
Only GET and HEAD requests are cacheable by spec.
Any other method (POST, PUT, etc.) is forwarded
directly to the origin without touching the cache.
cache.match(cacheKey) looks up the request URL in Cloudflare's
local data-center cache. A HIT returns the cached response
immediately — the origin is never contacted.
On a cache MISS, a plain fetch(request) is made
to the origin. No cf options are set here because we don't yet
know whether the origin will send max-age.
If the origin returns a non-2xx status (!originResponse.ok),
the error is passed through as-is. We don't cache 4xx or 5xx responses
to avoid poisoning the cache with failures.
A regex /\bmax-age=(\d+)\b/ reads the origin's
Cache-Control header. If max-age is present,
the Worker proceeds to override. If absent, the response passes through unmodified.
A mutable clone of the response is created. Its Cache-Control
is rewritten to set s-maxage=3600 (CDN TTL) while preserving
the original max-age (browser TTL). Then cache.put()
stores it non-blocking via ctx.waitUntil().
Plain JavaScript — no TypeScript required. Drop this into your worker.js.
export default {
async fetch(request, env, ctx) {
// Step 1 — Skip non-cacheable HTTP methods
if (!["GET", "HEAD"].includes(request.method)) {
return fetch(request);
}
const CLOUDFLARE_CDN_TTL = 3600; // 1 hour
const cache = caches.default;
const cacheKey = new Request(request.url, request);
// Step 2 — Return cached response if available (cache HIT)
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
return cachedResponse;
}
// Step 3 — Cache MISS: fetch from origin without cf overrides
const originResponse = await fetch(request);
// Step 4 — Do not cache error responses
if (!originResponse.ok) {
return originResponse;
}
// Step 5 — Read and parse the origin's Cache-Control header
const originCacheControl = originResponse.headers.get("Cache-Control") ?? "";
const maxAgeMatch = originCacheControl.match(/\bmax-age=(\d+)\b/);
if (maxAgeMatch) {
const originMaxAge = parseInt(maxAgeMatch[1], 10);
// Step 6a — Clone the response (origin Response is immutable)
const response = new Response(originResponse.body, originResponse);
// Step 6b — Override CDN TTL via s-maxage, preserve browser TTL
response.headers.set(
"Cache-Control",
`public, s-maxage=${CLOUDFLARE_CDN_TTL}, max-age=${originMaxAge}`
);
// Step 6c — Store in cache non-blocking (don't delay the response)
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
}
// Step 6d — No max-age from origin: pass through without caching
return originResponse;
},
};
Understanding the difference between browser TTL and CDN TTL is critical.
Cache-Control: public, s-maxage=3600, max-age=300
│ │
│ └─ Browser TTL: 300s (from origin)
└─ CDN/Cloudflare edge TTL: 3600s (overridden by Worker)
| Directive | Controls | Who sets it | Visible to browser? |
|---|---|---|---|
max-age |
Browser / client cache TTL | Origin (preserved by Worker) | Yes |
s-maxage |
CDN / proxy cache TTL (Cloudflare edge) | Worker (overridden to 3600) | No — stripped at edge |
cf.cacheTtl |
Cloudflare edge TTL, ignores all origin headers | fetch() cf option — set before request | No |
cf.cacheTtlByStatus |
Cloudflare edge TTL per HTTP status code | fetch() cf option — set before request | No |
Looks up the request in Cloudflare's local data-center cache.
Returns the cached Response or undefined on a miss.
Always check this first to avoid unnecessary origin requests.
Origin Response objects are immutable — you cannot modify their headers directly.
Cloning via new Response() creates a mutable copy so headers can be rewritten
before storing in the cache.
Stores the response in cache. .clone() is mandatory because the body stream
can only be consumed once — one copy is returned to the user, the other goes to cache.
Keeps the Worker alive to complete the cache.put() after the response has
already been sent to the client. Without this, the cache write would be cancelled when
the function returns.
Visual representation of the Worker's branching logic on every request.
Cloudflare automatically adds a CF-Cache-Status response
header so you can verify cache behavior in browser DevTools or curl -I.
Served from Cloudflare edge cache. Origin not contacted.
Not in cache. Fetched from origin. Response stored for next request.
Was cached but s-maxage TTL elapsed. Origin re-fetched.
Cache intentionally skipped (e.g. Cookie present, or no-store header).
Not cached — no matching cache rule or Cloudflare decided not to cache.
caches.default (Cache API) does not propagate through
Cloudflare's Tiered Cache network. If you rely on Tiered Cache for performance,
use fetch() with cf.cacheTtl instead (requires knowing
the TTL before the request).
The Cache API stores responses locally in each Cloudflare edge data center. A request hitting a different PoP will result in a MISS and re-fetch from origin until that PoP also warms its cache.
Always call response.clone() before cache.put().
Passing the same response to both the return value and cache.put()
will result in one of them receiving an empty body.
If the origin sends a Vary header (e.g. Vary: Accept-Encoding),
Cloudflare includes those request headers in the cache key. This can cause unexpected
cache fragmentation.