Cloudflare CDN in Practice: Cache Like a Pro with Correct Headers, Cache Rules, and a Tiny Worker
Cloudflare can make your site feel “instant,” but only if you tell it what to cache, for how long, and when to revalidate. Junior and mid-level devs often flip Cloudflare on and expect magic—then wonder why HTML isn’t cached, why assets don’t update, or why everything is “MISS.”
This hands-on guide walks you through a practical setup that works for most web apps: cache static assets aggressively, cache selected pages safely, keep APIs un-cached (or explicitly controlled), and add an edge worker for fine-grained behavior.
1) Start with a simple caching strategy
Use this baseline approach:
- Static assets (CSS/JS/images/fonts): cache for a long time (
1 year), version them (hash in filename), and setimmutable. - HTML: cache cautiously. If your site is mostly static or has “public pages,” cache those for a short time (e.g.,
60–300s) with revalidation. - APIs: default to
no-storeunless you have a clear caching plan (e.g., public catalog endpoints). - Authenticated content: never cache at the edge unless you’re doing careful personalization handling.
This strategy reduces risk and provides immediate performance gains.
2) Fix the #1 problem: missing or wrong cache headers
Cloudflare respects origin headers in many setups. If your app doesn’t send sane Cache-Control, your CDN configuration becomes harder and more error-prone.
Here are solid defaults you can implement today.
2.1) Node.js (Express) example: set cache headers for assets and HTML
This example assumes your static assets live in /public and your HTML is served by app routes.
import express from "express"; import path from "path"; const app = express(); // 1) Static assets: cache hard for 1 year (requires filename versioning!) app.use( "/assets", express.static(path.join(process.cwd(), "public/assets"), { setHeaders(res) { res.setHeader( "Cache-Control", "public, max-age=31536000, immutable" ); }, }) ); // 2) HTML routes: short cache + allow revalidation app.get("/", (req, res) => { res.setHeader("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=60"); res.send("<!doctype html><html><body>Home</body></html>"); }); // 3) API: do not cache by default app.get("/api/profile", (req, res) => { res.setHeader("Cache-Control", "no-store"); res.json({ user: "alice" }); }); app.listen(3000, () => console.log("Listening on http://localhost:3000"));
Notes:
max-ageis browser cache.s-maxageis shared caches (CDN/proxy).stale-while-revalidatehelps keep pages snappy while Cloudflare refreshes in the background.
2.2) Nginx example: cache static files and keep HTML short-lived
server { listen 80; server_name example.com; # Static assets location /assets/ { root /var/www/app/public; add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; } # HTML / app location / { proxy_pass http://app:3000; proxy_set_header Host $host; # If your app doesn't set headers, you can set a safe default here: add_header Cache-Control "public, max-age=60, s-maxage=300, stale-while-revalidate=60"; } # API location /api/ { proxy_pass http://app:3000; add_header Cache-Control "no-store"; } }
3) Verify caching with curl (don’t guess)
Cloudflare adds useful response headers. Two common ones:
cf-cache-status:HIT,MISS,BYPASS,EXPIRED, etc.age: seconds the object has been in cache
Run:
# First request (likely MISS) curl -I https://example.com/assets/app.3f2c1a9.js # Second request (should become HIT if cacheable) curl -I https://example.com/assets/app.3f2c1a9.js # Check HTML caching curl -I https://example.com/
If assets aren’t becoming HIT, check:
- Do you send
Cache-Control: public? - Are you accidentally sending
Set-Cookieon asset responses? - Are you using “Cache Everything” rules too broadly (causing surprises)?
4) Use Cloudflare Cache Rules safely
In Cloudflare’s dashboard, you can create Cache Rules (or legacy Page Rules) to control edge caching without touching code. A safe, practical ruleset looks like this:
- Rule A (assets): If URL path starts with
/assets/, set Edge TTL to1 year. - Rule B (images): If URL path matches
\.(png|jpg|jpeg|webp|svg|ico)$, set Edge TTL to30 days. - Rule C (API): If URL path starts with
/api/, bypass cache. - Rule D (HTML public pages): If path matches
/or/blog/*, set Edge TTL to5 minutesand respect origin where possible.
Tip: Avoid enabling “Cache Everything” for your whole site unless you fully understand how cookies, sessions, and logged-in pages behave.
5) Purge cache on deploy (targeted, not “purge everything”)
When you ship a new release, you want old HTML to refresh quickly, but you don’t want to blow away a year’s worth of cached assets if you’re using hashed filenames.
Preferred pattern:
- Assets are versioned (hash in filename) → no purge needed.
- HTML endpoints are purged by URL, or allowed to expire quickly via TTL.
Example: purge a couple of URLs via Cloudflare API (Node.js). You’ll need an API token with purge permissions and your zone ID.
/** * Purge specific URLs from Cloudflare cache. * Env: * - CF_API_TOKEN * - CF_ZONE_ID */ async function purgeUrls(urls) { const zoneId = process.env.CF_ZONE_ID; const token = process.env.CF_API_TOKEN; const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, { method: "POST", headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ files: urls }), }); const data = await res.json(); if (!data.success) { throw new Error(`Purge failed: ${JSON.stringify(data.errors)}`); } return data; } purgeUrls([ "https://example.com/", "https://example.com/blog/", ]).then(() => console.log("Purged")).catch(console.error);
6) Add a Cloudflare Worker: cache HTML for guests, bypass for logged-in users
Here’s a tiny Worker that:
- Caches GET requests for “guest” users
- Bypasses cache if a session cookie exists (customize cookie name)
- Adds a debug header so you can see what happened
// Cloudflare Worker (Module syntax) export default { async fetch(request, env, ctx) { const url = new URL(request.url); // Only cache GET for a subset of pages const isCacheablePath = url.pathname === "/" || url.pathname.startsWith("/blog/"); if (request.method !== "GET" || !isCacheablePath) { return fetch(request); } const cookie = request.headers.get("Cookie") || ""; const isLoggedIn = cookie.includes("session="); // change to your cookie name if (isLoggedIn) { const resp = await fetch(request, { cf: { cacheTtl: 0, cacheEverything: false } }); const newResp = new Response(resp.body, resp); newResp.headers.set("X-Edge-Cache", "BYPASS_LOGGED_IN"); return newResp; } // Guest: cache at edge for 5 minutes, allow serving stale briefly const resp = await fetch(request, { cf: { cacheEverything: true, cacheTtl: 300, cacheKey: url.toString(), }, }); const newResp = new Response(resp.body, resp); newResp.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=60"); newResp.headers.set("X-Edge-Cache", "CACHE_GUEST"); return newResp; }, };
Test it with:
# Guest curl -I https://example.com/ | grep -iE "cf-cache-status|x-edge-cache|cache-control" # Simulated logged-in user curl -I https://example.com/ -H "Cookie: session=abc123" | grep -iE "cf-cache-status|x-edge-cache"
7) Common pitfalls and how to avoid them
-
“My CSS/JS changes don’t show up!”
If you cache assets for a year, you must version filenames (e.g.,app.3f2c1a9.js). Never rely on purging for every tiny asset change. -
“HTML is cached for logged-in users.”
Don’t cache pages that vary by user. If you cache HTML, explicitly bypass based on cookies/session, or separate guest and auth subdomains. -
“Everything is MISS.”
CheckCache-Control, avoidno-storeon assets, ensure Cloudflare isn’t set to bypass, and confirm you’re not sending cookies on static responses. -
“I purged everything and now performance is worse.”
Purge only what changes (often HTML URLs). Keep long-lived assets stable via hashing.
8) A quick checklist you can apply today
- Serve static assets under a dedicated path like
/assets/. - Set
Cache-Control: public, max-age=31536000, immutablefor versioned assets. - Set short-lived caching for public HTML:
s-maxage=300(or similar) plusstale-while-revalidate. - Default APIs to
no-storeunless intentionally cacheable. - Create Cloudflare Cache Rules: cache assets, bypass APIs, cautiously cache public pages.
- Purge selectively (HTML URLs), not globally.
- Use
curl -Iand watchcf-cache-statusto verify behavior.
With these steps, you’ll get the real value of Cloudflare: fewer origin hits, faster page loads worldwide, and predictable deployments—without accidentally caching private data.
Leave a Reply