☁ Cloudflare Workers

Cache Control Override
via Workers

Force a 3600-second CDN edge cache TTL whenever an origin responds with a Cache-Control: max-age=x header.

Cache API s-maxage ctx.waitUntil cache.put() cache.match() JavaScript

The Core Problem

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.

Problem: You need to inspect the response header to decide the TTL, but the cf object must be provided before you can read that header. These two requirements are mutually exclusive with a single fetch() call.
🌐
Request arrives
GET / HTTP/1.1
📂
Check cache
cache.match()
📦
Fetch origin
No cf overrides yet
🔍
Inspect headers
Read max-age
💾
Store & respond
cache.put() + return

How the Worker Operates

Six logical steps executed on every incoming request.

1

Skip non-cacheable methods

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.

2

Check Cloudflare's cache first

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.

3

Fetch from origin (no cf override)

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.

4

Skip errors

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.

5

Parse Cache-Control max-age

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.

6

Override CDN TTL & store

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().

Complete Worker Code

Plain JavaScript — no TypeScript required. Drop this into your worker.js.

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;
  },
};

Cache-Control Header Anatomy

Understanding the difference between browser TTL and CDN TTL is critical.

Response header sent to browser
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

Why Each API Call Matters

🔍

cache.match(cacheKey)

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.

📦

new Response(body, init)

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.

💾

cache.put(key, response.clone())

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.

ctx.waitUntil()

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.

Decision Tree

Visual representation of the Worker's branching logic on every request.

Request received
  ├── Method is GET or HEAD?
  │    ├── NOfetch(request) and return (pass-through)
  │    └── YES → check cache
  │
  ├── cache.match() returns a response?
  │    ├── YESreturn cached response (CF-Cache-Status: HIT)
  │    └── NO → fetch from origin
  │
  ├── originResponse.ok (2xx)?
  │    ├── NOreturn error response (not cached)
  │    └── YES → parse Cache-Control
  │
  └── Origin header has max-age=x?
      ├── NOreturn originResponse (pass-through, not cached)
      └── YESclone → set s-maxage=3600 → cache.put() → return

CF-Cache-Status Header

Cloudflare automatically adds a CF-Cache-Status response header so you can verify cache behavior in browser DevTools or curl -I.

HIT

Served from Cloudflare edge cache. Origin not contacted.

MISS

Not in cache. Fetched from origin. Response stored for next request.

EXPIRED

Was cached but s-maxage TTL elapsed. Origin re-fetched.

BYPASS

Cache intentionally skipped (e.g. Cookie present, or no-store header).

DYNAMIC

Not cached — no matching cache rule or Cloudflare decided not to cache.

What to Watch Out For

No Tiered Cache support

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).

🔏

Cache is per data center

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.

🔄

Body can only be consumed once

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.

🔹

Vary header interactions

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.