Cloudflare CDN Caching You Can Actually Control: Rules + Headers + a Worker (Hands-On)
Cloudflare can make a site feel “instant”… or quietly serve stale HTML, cache private pages, or ignore the caching strategy you thought you configured. The trick is to treat Cloudflare caching like a three-layer system:
- Your origin headers (what your app/server says is cacheable)
- Cloudflare Cache Rules (what Cloudflare is allowed to cache and for how long)
- Optional Cloudflare Worker (when you need “app logic” at the edge)
This article walks through a practical setup most web apps need:
- Cache static assets aggressively
- Cache public HTML for a short time
- Never cache admin/auth/user-specific routes
- Optionally add an edge Worker for “micro-caching” HTML with safe bypass rules
Step 1: Start with sane origin caching headers
Cloudflare’s default caching behavior generally follows your origin’s cache headers unless overridden by an Edge Cache TTL rule. :contentReference[oaicite:0]{index=0}
For most apps, use these patterns:
- Static assets (fingerprinted): cache “forever” (1 year), immutable
- Public HTML pages: cache briefly (e.g., 60–300s) at the edge
- Private/auth pages: do not cache at all
Here’s an Express example that sets caching headers for static assets and basic HTML routes.
import express from "express"; import path from "path"; const app = express(); // 1) Fingerprinted assets: /assets/app.3f2a1c9.js app.use( "/assets", express.static(path.join(process.cwd(), "public/assets"), { setHeaders(res, filePath) { // Cache for 1 year. Safe when filenames change on deploy. res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); }, }) ); // 2) Public HTML: cache briefly at CDN (s-maxage), shorter in browser (max-age) app.get("/", (req, res) => { res.setHeader("Cache-Control", "public, max-age=60, s-maxage=300"); res.send("<h1>Home</h1>"); }); // 3) Private pages: never cache app.get("/dashboard", (req, res) => { res.setHeader("Cache-Control", "private, no-store"); res.send("<h1>Dashboard</h1>"); }); app.listen(3000, () => console.log("Listening on http://localhost:3000"));
Why s-maxage? Shared caches (like CDNs) can use s-maxage while browsers use max-age. That keeps the browser from “holding on” too long while still letting Cloudflare reduce origin load.
Step 2: Add Cloudflare Cache Rules (replacing Page Rules)
Cloudflare’s older “Page Rules” have been deprecated/limited for many accounts, and the modern way to tune caching is through Cache Rules and other rules products. :contentReference[oaicite:1]{index=1}
In Cache Rules, you can do two high-impact things:
- Bypass cache for sensitive paths
- Override Edge Cache TTL for paths you want cached even if your origin is conservative
Cloudflare documents a “Bypass cache” option directly in Cache Rules. :contentReference[oaicite:2]{index=2}
Rule set you can copy conceptually (exact UI clicks vary, but the logic is stable):
- Rule A (bypass): if path starts with
/adminOR/apiOR/loginOR/dashboard→ Bypass cache - Rule B (static): if path matches
/assets/*or file extensions.css,.js,.png,.jpg,.svg,.woff2→ set Edge Cache TTL high (e.g., 30 days+) and keep your origin’s immutable strategy - Rule C (public HTML): if method is
GETand path is/or/blog/*→ set Edge Cache TTL to 2–5 minutes (micro-cache)
Important gotcha: Edge Cache TTL may override behaviors like revalidation directives in some cases, so apply it intentionally and only where it makes sense. :contentReference[oaicite:3]{index=3}
Step 3: Know when Cloudflare will serve stale content
“Serve stale while you refresh in the background” is a great user experience, but it’s not magic. Cloudflare’s revalidation behavior is controlled by caching headers, and Cloudflare only serves stale during revalidation if your origin includes the stale-while-revalidate directive. :contentReference[oaicite:4]{index=4}
Example header for public HTML:
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30
This means:
- Browsers treat it fresh for 60 seconds
- Shared caches (Cloudflare) treat it fresh for 300 seconds
- After it’s stale, the cache may keep serving stale for 30 seconds while it revalidates
If you set this at the origin, Cloudflare can behave nicely under load spikes without making everyone wait for the origin to respond.
Step 4: Add a Worker for safe HTML micro-caching (with bypass)
Cache Rules are often enough. Use a Worker when you need “logic,” like:
- Bypass caching when a cookie indicates a logged-in session
- Micro-cache HTML for only anonymous users
- Normalize query strings (e.g., ignore
utm_*) before caching
Cloudflare Workers provide a Cache API (caches.default). Note: when using cache.put/cache.match, some directives like stale-while-revalidate are not supported in the way you might expect—so implement revalidation intentionally (or keep it simple). :contentReference[oaicite:5]{index=5}
Here’s a “micro-cache HTML for anonymous traffic” Worker you can deploy. It:
- Bypasses caching for non-GET requests
- Bypasses caching for admin/auth paths
- Bypasses caching if a cookie suggests a logged-in user
- Caches HTML responses for 120 seconds at the edge
export default { async fetch(request, env, ctx) { const url = new URL(request.url); // 1) Only cache GET/HEAD if (request.method !== "GET" && request.method !== "HEAD") { return fetch(request); } // 2) Never cache sensitive paths const sensitivePrefixes = ["/admin", "/login", "/dashboard", "/api"]; if (sensitivePrefixes.some((p) => url.pathname.startsWith(p))) { return fetch(request); } // 3) Never cache for logged-in users (example cookie names) const cookie = request.headers.get("Cookie") || ""; const looksLoggedIn = cookie.includes("session=") || cookie.includes("auth_token="); if (looksLoggedIn) { return fetch(request); } // 4) Normalize common marketing params so they don't explode your cache for (const [key] of url.searchParams) { if (key.startsWith("utm_") || key === "gclid" || key === "fbclid") { url.searchParams.delete(key); } } // Cache key includes the normalized URL const cacheKey = new Request(url.toString(), request); const cache = caches.default; const cached = await cache.match(cacheKey); if (cached) return cached; // Fetch from origin const originResp = await fetch(cacheKey); // Only cache successful HTML const contentType = originResp.headers.get("Content-Type") || ""; if (!originResp.ok || !contentType.includes("text/html")) { return originResp; } // Clone so we can modify headers const resp = new Response(originResp.body, originResp); // Edge micro-cache (2 minutes). Browser cache kept short. resp.headers.set( "Cache-Control", "public, max-age=0, s-maxage=120" ); // Store asynchronously ctx.waitUntil(cache.put(cacheKey, resp.clone())); return resp; }, };
Where to deploy this: attach it to routes like example.com/* or just /blog/* depending on your app. Keep it narrow until you trust it.
Step 5: Debug caching like a pro (quick checklist)
- Confirm what your origin sends: check
Cache-ControlandContent-Type - Check Cloudflare behavior: does your Cache Rule override TTL or bypass?
- Be careful with HTML “cache everything” patterns: they can leak personalized pages if you don’t bypass cookies/auth routes
- Keep browser TTLs shorter than edge TTLs for HTML: so you can ship fixes without users stuck on an old page
If you’re relying on “serve stale while revalidating,” ensure your origin includes stale-while-revalidate where appropriate. :contentReference[oaicite:6]{index=6}
A practical starting configuration
If you want a simple baseline that works for most junior/mid teams:
- Origin: immutable 1-year cache for fingerprinted assets; short
s-maxagefor public HTML;no-storefor private routes - Cloudflare Cache Rules: bypass
/admin,/api,/login,/dashboard; set Edge TTL for/assets/*and public HTML routes - Optional Worker: micro-cache only anonymous HTML, normalize tracking query params
That gives you faster load times, fewer origin hits, and much lower risk of caching the wrong thing—without turning your caching layer into a mystery box.
::contentReference[oaicite:7]{index=7}
Leave a Reply