· cloudflare / cloudflare-pages / cloudflare-workers

How to cache static assets the right way on Cloudflare

Set Cache-Control for fingerprinted assets, index.html, and service workers on Cloudflare. Verify with cf-cache-status; six BYPASS gotchas covered.

By

2,036 words · 11 min read

Most Cloudflare deployments leave performance on the table. The cf-cache-status: MISS on every asset request is the tell. Not because caching is hard, but because Cloudflare’s edge does not behave like a generic CDN — it has its own rules about what it will cache, when it will bypass, and how it interacts with the headers you set.

This tutorial fixes that. You will leave with correct headers for every asset type, working config for both Cloudflare Pages and Workers, and a verification checklist to confirm it is actually working.

Who this is for

Developers deploying static sites or SPAs on Cloudflare Pages or Workers who are getting MISS or BYPASS on assets that should be cached. Assumes basic familiarity with Cloudflare. If you are evaluating Cloudflare against other platforms, see Cloudflare Workers vs Vercel Edge.

How Cloudflare edge caching works

Cloudflare caches at its edge — geographically distributed Points of Presence (PoPs) that sit between your users and your origin. When a request arrives at an edge node and the asset is in cache, it never reaches your server. That is the win.

What Cloudflare caches by default: file extension, not MIME type. Extensions like .css, .js, .png, .jpg, .woff2, and 60+ other static types are cached automatically. HTML, JSON, and XML are not — Cloudflare treats them as dynamic unless you explicitly tell it otherwise.

Default edge TTL when no Cache-Control header is present:

HTTP statusEdge TTL
200, 206, 301120 minutes
302, 30320 minutes
404, 4103 minutes

When Cloudflare will not cache, regardless of file type:

  • Cache-Control: private, no-store, no-cache, or max-age=0 is present
  • A Set-Cookie header is in the response (covered in gotchas)
  • HTTP method is not GET

Edge cache vs. browser cache: max-age controls both. s-maxage controls the edge only — browsers ignore it. This matters: set different TTLs for edge and browser with s-maxage=86400, max-age=0 to cache aggressively at Cloudflare while forcing browsers to always revalidate.

Every Cloudflare response includes a cf-cache-status header that tells you exactly what happened. That is what you will use to verify everything is working.

Decision tree: correct headers by asset type

Get this part right first. Wrong headers here is the root cause of most BYPASS and DYNAMIC responses.

Fingerprinted assets (bundle.abc123.js, main.7f3e1a.css)

Tools like Vite, Astro, and Next.js produce content-hashed filenames by default. The hash changes with the file content. That means you can cache them forever — the URL will never serve stale content.

Cache-Control: public, max-age=31536000, immutable
  • public — cacheable by the CDN edge and browsers
  • max-age=31536000 — 1 year in browsers
  • immutable — tells the browser not to revalidate on reload; eliminates conditional GET requests on F5

Note: immutable has no effect on Cloudflare’s edge. Edge behavior is controlled by max-age (or s-maxage). Include it anyway — it helps browser performance.

index.html and other entry points (not fingerprinted)

These must be fresh on every visit. A stale index.html pointing to old asset hashes breaks the app.

Cache-Control: no-cache

no-cache does not mean “don’t cache.” It means “cache it, but revalidate before serving.” Cloudflare will store the response, issue a conditional request to origin, and serve fresh content if it changed. That is the right behavior here.

If you want to allow short edge caching:

Cache-Control: public, s-maxage=60, max-age=0, must-revalidate

This caches at the edge for 60 seconds (useful to absorb traffic spikes on deploy) while forcing browsers to always check.

Service workers (/sw.js)

Never cache service workers long-term. A stale service worker that references old asset URLs will serve broken content until the user manually clears the cache — or longer if the update loop itself is broken.

Cache-Control: no-store

no-store means nothing is saved anywhere. Not in the edge, not in the browser. Always fetched fresh.

API responses and dynamic content

Cache-Control: private, no-cache

private tells Cloudflare’s edge not to cache the response at all — browser only. no-cache lets the browser store it but requires revalidation. This combination protects user-specific data while still enabling conditional request optimization in the browser.

Step 1: Configure Cloudflare Pages with _headers

The _headers file is a plain text file in your build output directory — the same folder as your index.html. Cloudflare Pages reads it on deploy and applies the rules.

# Fingerprinted assets — cache forever
/assets/*
  Cache-Control: public, max-age=31536000, immutable

# Fonts — also fingerprinted in most build setups
/fonts/*
  Cache-Control: public, max-age=31536000, immutable

# HTML entry points — force revalidation
/
  Cache-Control: no-cache

/*.html
  Cache-Control: no-cache

# Service worker — no storage at all
/sw.js
  Cache-Control: no-store

Three limits to know:

  1. Max 100 header rules per _headers file.
  2. 2,000-character limit per line.
  3. _headers has no effect on Pages Functions responses. If a URL is served by a Function (i.e., there is a matching file in /functions), the _headers file is ignored for that route. Set headers in the Function’s Response instead.

For a typical Vite or Astro build, your fingerprinted assets land in /assets/ and the _headers file covers everything. Check your build output directory — the file must be there, not in your source root, for Cloudflare to pick it up.

If you are setting up Cloudflare Pages from scratch, How to deploy an Astro site on Cloudflare Pages walks through the full build configuration including _headers file placement.

Step 2: Configure Cloudflare Workers with response headers

If you are serving assets from a Worker rather than Pages, set headers on the Response object directly.

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const response = await fetch(request);

    // Clone response so headers are mutable
    const newResponse = new Response(response.body, response);

    if (url.pathname.startsWith('/assets/')) {
      newResponse.headers.set(
        'Cache-Control',
        'public, max-age=31536000, immutable'
      );
    } else if (
      url.pathname === '/' ||
      url.pathname.endsWith('.html')
    ) {
      newResponse.headers.set('Cache-Control', 'no-cache');
    } else if (url.pathname === '/sw.js') {
      newResponse.headers.set('Cache-Control', 'no-store');
    }

    return newResponse;
  }
};

Workers Cache API (for programmatic control)

Use the Cache API when you need to cache content that does not have cache headers, or when you want to cache and serve within the Worker without forwarding to origin on every request.

const cache = caches.default;
const cacheKey = new Request(request.url, request);

let response = await cache.match(cacheKey);
if (!response) {
  response = await fetch(request);
  const newRes = new Response(response.body, response);
  // s-maxage controls edge TTL; max-age controls browser TTL
  newRes.headers.append('Cache-Control', 's-maxage=86400');
  ctx.waitUntil(cache.put(cacheKey, newRes.clone()));
}
return response;

One important limitation: cache.delete() removes content from the local PoP only. To purge globally, use the Cloudflare Cache Purge API — the Workers Cache API does not replicate purges across data centers.

Workers cf fetch options

An alternative to header manipulation. The cf property on fetch controls caching without touching response headers:

const response = await fetch(request, {
  cf: {
    cacheTtl: 86400,           // cache for 24h regardless of headers
    cacheEverything: true,     // cache HTML and other non-default types
    cacheTtlByStatus: {        // per-status TTL granularity
      "200-299": 86400,
      "404": 60,
      "500-599": 0
    },
    cacheTags: ["product-images"]  // for tag-based purge
  }
});

Use cf options when you want Cloudflare-specific behavior without polluting the response headers that browsers see.

Step 3: Verify with cf-cache-status

After deploying, check that the headers are actually working.

# First request — cold cache, should be MISS
curl -sI https://yoursite.com/assets/app.abc123.js | grep cf-cache-status
# cf-cache-status: MISS

# Second request from same region — should be HIT
curl -sI https://yoursite.com/assets/app.abc123.js | grep cf-cache-status
# cf-cache-status: HIT

# Verify the actual Cache-Control on fingerprinted assets
curl -sI https://yoursite.com/assets/app.abc123.js | grep cache-control
# cache-control: public, max-age=31536000, immutable

# Verify no-cache on index.html
curl -sI https://yoursite.com/ | grep cache-control
# cache-control: no-cache

# Check for Set-Cookie on static assets (bad if present)
curl -sI https://yoursite.com/assets/app.abc123.js | grep -iE "set-cookie|cf-cache-status"

In browser DevTools: Network tab → select the asset → Response Headers → look for cf-cache-status.

All cf-cache-status values:

ValueMeaning
HITServed from Cloudflare edge — ideal
MISSNot in cache; fetched from origin
EXPIREDWas cached, expired; fetched fresh from origin
REVALIDATEDValidated with origin (304), served from cache
UPDATINGServing stale while fetching fresh in background
STALEServed stale; origin unreachable
BYPASSOrigin told Cloudflare not to cache
DYNAMICAsset not eligible for cache; no rule forces it
NONE/UNKNOWNGenerated by Workers, WAF, or redirect rules

If you see BYPASS on a .js or .css file, the gotchas section explains why.

Gotchas

These are the specific issues that produce BYPASS or DYNAMIC on assets that should be HIT.

If your origin (or any middleware in the stack) sets a Set-Cookie header on a response — even a static .js file — Cloudflare bypasses the cache and returns cf-cache-status: BYPASS. This is RFC 7234 compliant behavior, but it surprises every developer the first time.

The fix: ensure no middleware sets cookies on static asset paths. If you cannot change the origin behavior, create a Cache Rule scoped to /assets/* and strip cookies from the cache key.

DYNAMIC vs. BYPASS

These are different. DYNAMIC means the asset was never considered cacheable — wrong file type, or a Cache Rule with “Bypass cache” matched too early. BYPASS means the asset was eligible but the origin told Cloudflare not to cache it. If you see DYNAMIC on a .js file, check whether a broad “Bypass cache” rule is firing on a pattern that includes your asset paths.

Browser Cache TTL dashboard setting overrides origin headers

Cloudflare’s Browser Cache TTL setting in the dashboard can override your origin’s max-age. Set a 1-year max-age in origin headers, and if the dashboard Browser Cache TTL is set to “4 hours,” browsers cache for 4 hours. Always align the dashboard setting with your origin headers, or leave it at “Respect Existing Headers.”

Cache Reserve has eventual-consistency purges

If you have Cache Reserve enabled (Cloudflare’s R2-backed persistent edge cache), purge-by-tag operations are not instant. Purge-reliant deploy workflows will briefly serve stale content. The better approach: content-hashed filenames. Vite, Astro, and Next.js all produce hashed filenames by default. Hash changes on content change; you never need to purge.

immutable is browser-only

immutable is a browser directive. Cloudflare’s edge ignores it. Edge behavior is entirely controlled by max-age and s-maxage. Include immutable in your headers anyway — it eliminates browser-side conditional GET requests on fingerprinted assets — but do not count on it to affect Cloudflare.

Service workers must use no-store

Browsers are aggressive about caching service workers. A cached /sw.js pointing to old asset hashes can serve a broken app indefinitely. The fix is Cache-Control: no-store on the service worker path. This is the one case where no-store is the correct value, not no-cache.

Summary

The rules distilled:

Asset typeCorrect Cache-Control
Fingerprinted JS/CSS/imagespublic, max-age=31536000, immutable
index.html, entry pointsno-cache
Service worker (/sw.js)no-store
API / user-specific responsesprivate, no-cache

Pages projects: put a _headers file in the build output directory. Workers projects: set headers on the Response object or use cf fetch options.

After deploying, run curl -sI against your assets and confirm cf-cache-status: HIT on the second request. If you see BYPASS, check for Set-Cookie in the response. If you see DYNAMIC, check for an overly broad Cache Rule.

Content-hashed filenames make all of this easier. Let Vite, Astro, or Next.js handle the hashing, then cache aggressively and never worry about purge timing.

For Workers deployments, rate limiting Cloudflare Workers without Redis is the natural next step once caching is locked in.

References