Cloudflare CDN in Practice: Cache What Matters, Keep It Fresh, and Debug It Fast
Cloudflare can make your site feel “instantly faster” — but only if you’re intentional about what gets cached, how it’s invalidated, and how you troubleshoot cache behavior. This guide walks through a practical setup for junior/mid web developers: caching static assets aggressively, caching HTML safely, bypassing cache for authenticated users, adding the right headers, and debugging with simple tools.
We’ll assume you have a typical web app:
- A backend (Node/Laravel/Django/etc.) serving HTML and APIs
- Static assets (JS/CSS/images/fonts) built with a bundler
- Some pages are public, some are personalized (logged-in)
1) Start With a Simple Rule: Cache Static Assets Hard
Your biggest win is static assets. These should be:
- Fingerprint/versioned (e.g.,
app.3f2c1d.js) so you can cache them “forever” - Served with long cache headers
- Allowed to be cached by the CDN
On your origin (Nginx example), set strong caching for assets:
server { # ... location /assets/ { # fingerprinted files like /assets/app.3f2c1d.js add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; } location ~* \.(png|jpg|jpeg|gif|svg|webp|ico|woff2|css|js)$ { add_header Cache-Control "public, max-age=31536000, immutable"; } }
immutable tells browsers “don’t revalidate this file until it expires.” This is safe only when filenames change on deploy (fingerprints).
In Cloudflare, you can keep it even simpler: create a “Cache Rule” that targets /assets/* (or your build directory) and sets Edge TTL to something long. Prefer origin headers when possible, but using Cloudflare rules is fine if your origin config is limited.
2) Cache HTML Carefully (and Don’t Cache Personalized Pages)
HTML caching is where people get burned. If your HTML changes per user (greeting, cart count, “My Account”), you must avoid caching those responses at the edge.
A practical approach:
- Public marketing pages: cache for a short period (e.g., 1–5 minutes) or “stale-while-revalidate” style
- Authenticated pages: bypass cache entirely
- API responses: cache only if truly public and safe
First, make your origin explicit. For authenticated HTML pages, send:
Cache-Control: private, no-store(strong “don’t cache”) orCache-Control: private, max-age=0withVaryas needed
Example in Express (Node) to prevent caching for logged-in routes:
app.use((req, res, next) => { // Very simplified: treat presence of a session cookie as "logged in" const hasSession = Boolean(req.headers.cookie?.includes("session=")); if (hasSession) { res.setHeader("Cache-Control", "private, no-store"); } next(); }); app.get("/dashboard", (req, res) => { res.send("<h1>Dashboard</h1>"); });
Then, for public pages you can allow short caching:
app.get("/", (req, res) => { res.setHeader("Cache-Control", "public, max-age=60"); res.send("<h1>Home</h1>"); });
In Cloudflare, set up cache rules like:
if path starts with /assets/→ cache longif path starts with /api/→ usually bypass (unless explicitly safe)if cookie contains session=→ bypass cache
If you can’t match cookies easily (or want more control), a Worker is a great “escape hatch.”
3) Use a Cloudflare Worker to Bypass Cache for Logged-in Users
This Worker checks for a session cookie and forces “no-store” behavior. It also adds a debug header so you can confirm behavior in your browser or via curl.
export default { async fetch(request, env, ctx) { const url = new URL(request.url); const cookie = request.headers.get("Cookie") || ""; // Customize this for your app/session cookie name(s) const isLoggedIn = cookie.includes("session=") || cookie.includes("auth_token="); // Never cache authenticated HTML if (isLoggedIn && request.method === "GET" && acceptsHtml(request)) { const originResp = await fetch(request, { cf: { cacheTtl: 0, cacheEverything: false } }); return withHeaders(originResp, { "Cache-Control": "private, no-store", "X-Cache-Policy": "BYPASS_AUTH_HTML", }); } // Cache static assets aggressively at the edge (safe if fingerprinted) if (url.pathname.startsWith("/assets/")) { const resp = await fetch(request, { cf: { cacheEverything: true, cacheTtl: 60 * 60 * 24 * 365 }, }); return withHeaders(resp, { "X-Cache-Policy": "ASSET_LONG_TTL" }); } // Default: respect origin caching const resp = await fetch(request); return withHeaders(resp, { "X-Cache-Policy": "ORIGIN_HEADERS" }); }, }; function acceptsHtml(request) { const accept = request.headers.get("Accept") || ""; return accept.includes("text/html"); } function withHeaders(resp, extra) { const newResp = new Response(resp.body, resp); for (const [k, v] of Object.entries(extra)) newResp.headers.set(k, v); return newResp; }
Notes:
cacheEverythingis powerful — use it only where safe (like assets).- For HTML, it’s usually best to let origin headers drive caching, and explicitly bypass for auth.
- That
X-Cache-Policyheader is gold for debugging.
4) Make Cache Busting a Non-Issue
Cache invalidation gets scary when you deploy new JS/CSS but clients keep old files. The reliable strategy is:
- Fingerprint assets (content hash in filename)
- Set long TTL +
immutable - Update HTML to reference new filenames on deploy
Most build tools support this by default:
- Vite:
build.rollupOptions.output.entryFileNamesuses hashes by default in production - Webpack:
[contenthash] - Laravel Mix/Vite: versioned assets in
public/build
If you’re not fingerprinting assets, you’ll be stuck purging CDN cache on every deploy, and users will still hit stale files in their browser cache. Fingerprinting is the clean fix.
5) Debugging: Know Whether You’re Hitting Cache
When something “doesn’t update,” you need quick answers:
- Is Cloudflare caching it?
- Is the browser caching it?
- Is the origin sending cache headers you didn’t expect?
Use curl to inspect headers:
curl -I https://example.com/assets/app.3f2c1d.js
Look for:
cache-control(browser caching policy)cf-cache-status(Cloudflare edge cache status: HIT/MISS/BYPASS/DYNAMIC)- Your debug header (e.g.,
x-cache-policy)
Example output you want for assets:
cache-control: public, max-age=31536000, immutable cf-cache-status: HIT x-cache-policy: ASSET_LONG_TTL
For authenticated HTML you want:
cache-control: private, no-store cf-cache-status: BYPASS x-cache-policy: BYPASS_AUTH_HTML
Browser tip: open DevTools → Network → click a request → “Response Headers.” If the browser says “(from disk cache)”, Cloudflare isn’t even in the picture for that request.
6) Avoid Common CDN Foot-Guns
-
Accidentally caching API responses with user data
If an API response depends on auth, set
Cache-Control: private, no-storeand avoid edge caching. -
Ignoring
VaryIf your origin serves different content based on headers (like
Accept-Encodingor locale), ensureVaryis correct. Otherwise, caches may mix variants. -
Caching HTML “forever”
HTML changes often. Keep TTL short unless you have a robust purge strategy.
-
Not separating static vs dynamic paths
Keep assets under a clear prefix like
/assets/or/static/so caching rules are easy and safe.
7) A Practical “Good Default” Setup
If you want a sane baseline that works for most apps:
-
Static assets: fingerprinted,
Cache-Control: public, max-age=31536000, immutable, Cloudflare caches aggressively -
Public HTML pages:
Cache-Control: public, max-age=60(or 120/300), optionally use Cloudflare to cache for a few minutes -
Authenticated HTML + authenticated APIs:
Cache-Control: private, no-store, Cloudflare bypass via rule or Worker -
Debug headers: add
X-Cache-Policy(Worker) and usecf-cache-statusfor quick checks
This gives you most of the performance benefits with minimal risk. As you mature, you can add smarter patterns (e.g., caching certain public API endpoints, using stale-while-revalidate strategies, or edge rendering with Workers), but the foundation remains the same: cache static hard, cache HTML carefully, and never cache personalized content by accident.
If you want, I can also provide a “copy/paste” Cloudflare rules checklist (paths + actions) tailored to your stack (Next.js, Laravel, Rails, etc.).
Leave a Reply