Cloudflare CDN in Practice: Cache Rules, API Purge, and “Why Isn’t It Caching?” Debugging
Cloudflare can make a web app feel instantly faster—if you control what gets cached, for how long, and how to purge safely. This hands-on guide shows a junior/mid dev workflow that actually works in production: set cache behavior for static assets and HTML, add versioned URLs, purge via API after deploys, and debug the most common “Cloudflare isn’t caching” surprises.
What you’ll build
- A caching strategy that’s safe for dynamic apps (HTML + APIs) and great for static assets
- Versioned asset URLs so you rarely need to purge
- A small deploy script to purge Cloudflare cache via API
- A debugging checklist using response headers and DevTools
1) Know what Cloudflare will (and won’t) cache
Cloudflare sits in front of your origin and can cache responses. But caching is not “on/off”—it depends on:
Cache-Controlheaders returned by your origin- Whether the content is considered static (images, CSS, JS) vs HTML
- Cloudflare Cache Rules / Page Rules (plan-dependent) overriding defaults
- Cookies, query strings, and response status codes
A safe default for most web apps:
- Static assets (CSS/JS/images): cache “forever” with versioned URLs
- HTML: either don’t cache, or cache briefly with revalidation (only if you understand your app’s personalization)
- APIs: usually no cache unless explicitly designed for it
2) Cache static assets like a pro (immutable + versioned)
The best caching is the caching you never need to purge. The trick is to put a version (hash) in the filename so when the file changes, the URL changes.
Example asset URLs:
/assets/app.3f2a1c9b.js/assets/styles.91c0d2aa.css
Then your origin can serve these with very long cache headers:
# Example HTTP headers for versioned assets Cache-Control: public, max-age=31536000, immutable
Node/Express example (serving a dist/ folder):
import express from "express"; import path from "path"; const app = express(); const dist = path.resolve("dist"); // Serve assets with long cache app.use("/assets", express.static(path.join(dist, "assets"), { setHeaders(res) { res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); } })); // Serve HTML with safer defaults (we’ll tune later) app.get("*", (req, res) => { res.setHeader("Cache-Control", "no-store"); res.sendFile(path.join(dist, "index.html")); }); app.listen(3000, () => console.log("Listening on :3000"));
Why it works: Cloudflare loves caching files that clearly won’t change. When you deploy a new build, users naturally request new URLs (new hashes), so there’s nothing stale to purge.
3) Add a Cloudflare Cache Rule for assets (optional but nice)
If your origin headers are inconsistent (or multiple services serve assets), add a Cloudflare Cache Rule to enforce caching for /assets/* (or *.css, *.js, etc.). The goal is:
- Cache everything under
/assets/* - Set edge TTL (e.g., 1 year)
- Respect “immutable” behavior by never needing frequent purges
Even if you do this at Cloudflare, still set good Cache-Control at the origin. You want consistent behavior across browsers, other CDNs, and local testing.
4) Carefully cache HTML (only if it’s not personalized)
HTML caching is where people break logins and show user A’s content to user B. If your HTML is personalized (user-specific nav, CSRF tokens embedded, etc.), do not cache it at the edge.
But if you have public pages (marketing site, docs, blog), you can cache HTML with a short TTL plus revalidation:
# Good for public HTML that updates occasionally Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30
max-ageaffects browserss-maxageaffects shared caches (CDNs like Cloudflare)stale-while-revalidatecan keep things fast during refresh
Example: Nginx snippet for a public /blog/ section:
location /blog/ { add_header Cache-Control "public, max-age=60, s-maxage=300, stale-while-revalidate=30"; proxy_pass http://app_upstream; }
For logged-in areas, keep it strict:
# Logged-in pages Cache-Control: no-store
5) Purge Cloudflare cache via API after deploy
If you use versioned assets, you’ll purge far less. But you’ll still sometimes want to purge:
/index.htmlor HTML routes after a release- Critical JSON feeds
- Accidentally cached content
Cloudflare supports purging by URL or “purge everything”. Prefer URL purges.
Option A: API Token + Zone ID (recommended). Create an API token with “Cache Purge” permission for the zone.
#!/usr/bin/env bash set -euo pipefail : "${CF_API_TOKEN:?Missing CF_API_TOKEN}" : "${CF_ZONE_ID:?Missing CF_ZONE_ID}" # URLs to purge (adjust to your app) URLS=( "https://example.com/" "https://example.com/index.html" "https://example.com/blog/" ) payload=$(printf '%s\n' "${URLS[@]}" | jq -R . | jq -s '{files: .}') curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \ -H "Authorization: Bearer ${CF_API_TOKEN}" \ -H "Content-Type: application/json" \ --data "${payload}" | jq .
This script uses jq to build JSON safely. In CI, you can run it after deployment. If you don’t have jq, you can hardcode JSON, but escaping URLs gets annoying.
Option B: Purge everything (use sparingly)
curl -sS -X POST "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \ -H "Authorization: Bearer ${CF_API_TOKEN}" \ -H "Content-Type: application/json" \ --data '{"purge_everything":true}'
Use this only when you’ve messed up caching rules badly. It increases origin load and can cause a temporary slowdown until caches warm again.
6) Debug caching with headers (the fastest way)
When someone says “Cloudflare isn’t caching,” don’t guess—look at headers.
Check with curl:
curl -I https://example.com/assets/app.3f2a1c9b.js
Look for:
CF-Cache-Status: common values includeHIT,MISS,REVALIDATED,DYNAMICCache-Control: from your origin (or modified by Cloudflare)ETag/Last-Modified: enables efficient revalidationVary: can fragment cache unexpectedly (e.g.,Vary: Accept-Encodingis normal;Vary: Cookieis often a problem)
Common causes of DYNAMIC or endless MISS:
Cache-Control: no-storeorprivatereturned by origin- HTML or an endpoint Cloudflare avoids caching by default
- A cache rule bypassing cache for that path
- Cookies being set on assets (don’t do that)
- Query string behavior: your cache key might treat each query as unique
Pro tip: Open DevTools → Network → click the request → check response headers. You can also use “Disable cache” during dev, but remember that affects your local browser, not Cloudflare.
7) Avoid the two most common foot-guns
Foot-gun #1: Caching API responses that include user data
If you ever cache /api/me or any session-based response at the edge, you risk leaking data. Safe default:
# For session-based API endpoints Cache-Control: no-store
If you have truly public API responses (e.g., /api/public-feed), you can cache with a short TTL:
# For public API endpoints Cache-Control: public, max-age=30, s-maxage=300
Foot-gun #2: Relying on purges instead of versioning
If your /assets/app.js changes on every deploy but the URL stays the same, you’ll fight stale files constantly. Fix your build pipeline to emit hashed filenames and update references in HTML.
8) A simple “starter policy” you can copy
- Versioned assets (
/assets/*):Cache-Control: public, max-age=31536000, immutable - Public HTML (
/blog/*, docs):Cache-Control: public, max-age=60, s-maxage=300 - Authenticated HTML (
/app/*):Cache-Control: no-store - Authenticated APIs (
/api/*with cookies/auth):Cache-Control: no-store - Purge: purge only HTML entry points (and a few critical URLs) after deploy
9) Quick troubleshooting checklist
- Is
CF-Cache-StatusshowingHITfor versioned assets after the first request? - Does the origin send the right
Cache-Controlfor each route type (assets vs HTML vs API)? - Are you accidentally setting cookies on asset responses?
- Are query strings creating endless cache variants (e.g.,
?v=123)? - Did you purge the correct URLs (not just “the homepage” when the issue is
/index.htmlor a specific route)?
If you implement the versioned-asset approach and keep HTML/API caching conservative, you’ll get most of the performance upside with minimal risk—and debugging becomes a matter of reading headers instead of guessing.
Leave a Reply