· 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 Ethan
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 status | Edge TTL |
|---|---|
| 200, 206, 301 | 120 minutes |
| 302, 303 | 20 minutes |
| 404, 410 | 3 minutes |
When Cloudflare will not cache, regardless of file type:
Cache-Control: private,no-store,no-cache, ormax-age=0is present- A
Set-Cookieheader 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 browsersmax-age=31536000— 1 year in browsersimmutable— 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:
- Max 100 header rules per
_headersfile. - 2,000-character limit per line.
_headershas no effect on Pages Functions responses. If a URL is served by a Function (i.e., there is a matching file in/functions), the_headersfile is ignored for that route. Set headers in the Function’sResponseinstead.
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:
| Value | Meaning |
|---|---|
HIT | Served from Cloudflare edge — ideal |
MISS | Not in cache; fetched from origin |
EXPIRED | Was cached, expired; fetched fresh from origin |
REVALIDATED | Validated with origin (304), served from cache |
UPDATING | Serving stale while fetching fresh in background |
STALE | Served stale; origin unreachable |
BYPASS | Origin told Cloudflare not to cache |
DYNAMIC | Asset not eligible for cache; no rule forces it |
NONE/UNKNOWN | Generated 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.
The Set-Cookie trap
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 type | Correct Cache-Control |
|---|---|
| Fingerprinted JS/CSS/images | public, max-age=31536000, immutable |
index.html, entry points | no-cache |
Service worker (/sw.js) | no-store |
| API / user-specific responses | private, 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.