Cloudflare CDN for Web Apps: Cache Rules, Asset Versioning, and Safe HTML Caching (Hands-On)
Cloudflare can make your site feel dramatically faster—if you cache the right things in the right way. Junior and mid-level developers often get tripped up by two areas: caching dynamic HTML (which can leak personalized pages) and getting “why isn’t my change live?” after a deploy.
This hands-on guide shows a practical setup you can apply today:
- Cache static assets aggressively (CSS/JS/images) with versioned URLs.
- Cache HTML safely using rules, cookies, and bypass patterns.
- Debug cache behavior quickly with headers and curl.
- Invalidate cache when you must, but avoid purges by designing for cacheability.
1) The Mental Model: Browser Cache vs CDN Cache vs Origin
When you request /app.css, at least three caches may be involved:
- Browser cache: Controlled by response headers like
Cache-Control. - Cloudflare edge cache: Stores responses close to users. Controlled by Cloudflare rules + origin headers.
- Your origin (app server / storage): The source of truth.
Your goal is to make static assets “boringly cacheable” for a long time and treat HTML as “usually dynamic unless proven safe.”
2) Cache Static Assets Forever (With Versioned Filenames)
The safest way to cache static files for months is to change the URL when the content changes. That’s typically done with a content hash in the filename:
/assets/app.3f2a9c1.css/assets/app.9c0b11a.js
Then you can confidently serve:
Cache-Control: public, max-age=31536000, immutable
Here’s an example Node.js/Express snippet serving hashed assets with long caching:
import express from "express"; import path from "path"; const app = express(); const dist = path.join(process.cwd(), "public"); // Serve versioned assets with long cache app.use("/assets", express.static(path.join(dist, "assets"), { setHeaders(res, filePath) { // If filenames are hashed, they can be cached "forever" res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); } })); // Everything else (HTML) should be shorter by default 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"));
Key point: Your HTML references versioned asset URLs. If you redeploy and the JS changes, the filename changes, and users fetch the new file without you purging anything.
3) Cloudflare Caching: “Cache Everything” Can Hurt You
Cloudflare can cache HTML too, but you should not blindly cache pages that depend on:
- Login/session cookies
- Geo/location personalization
- AB tests tied to cookies
- Cart contents / account pages
Instead, use a safer approach:
- Cache static assets aggressively.
- Cache HTML only for public pages and only when you can confidently separate anonymous vs authenticated users.
4) Safe HTML Caching Pattern: Anonymous Cache + Auth Bypass
A common and safe pattern for sites with both public and logged-in areas:
- Cache HTML for
/,/blog/*,/docs/* - Bypass cache when request has auth cookies (like
session,jwt,auth) - Never cache admin, account, checkout
On the origin, you can be explicit about what is cacheable:
// Example Express middleware: mark public pages as cacheable, private as no-store function setCacheHeaders(req, res, next) { const hasAuthCookie = (req.headers.cookie || "").includes("session=") || (req.headers.cookie || "").includes("auth=") || (req.headers.cookie || "").includes("jwt="); const isPrivatePath = /^\/(account|admin|checkout|api)\b/.test(req.path); if (hasAuthCookie || isPrivatePath) { res.setHeader("Cache-Control", "no-store"); } else { // Public HTML: cache briefly at CDN, keep browser cache short res.setHeader("Cache-Control", "public, max-age=0, s-maxage=300"); } next(); }
s-maxage is respected by shared caches (like CDNs). This header says:
- Browser: don’t cache (
max-age=0) - CDN: cache for 5 minutes (
s-maxage=300)
Cloudflare can honor origin cache headers, but many teams also add explicit Cloudflare Cache Rules. The origin headers remain your “truth,” and Cloudflare rules reinforce them.
5) Cache Rules: Practical Recipes
Conceptually, you want rules that behave like this:
- Rule A (Assets): if URL matches
/assets/*, cache at edge for a long time - Rule B (Public HTML): if URL matches
/blog/*and no auth cookie, cache for 5 minutes - Rule C (Private): if URL matches
/account/*or has auth cookie, bypass cache
If you can’t express “no auth cookie” easily in your rules, keep HTML caching controlled by origin headers (s-maxage) and set Cloudflare to respect origin caching for HTML. The simplest safe baseline is: “cache assets only; HTML is no-store.” Then later, selectively opt in to HTML caching for clearly public pages.
6) Debugging: Read the Headers (Always)
When someone says “Cloudflare isn’t caching,” check the cache headers and Cloudflare’s cache status header. Use curl:
curl -I https://example.com/blog/my-post
Look for:
Cache-Control: what your origin told caches to doETag/Last-Modified: validators for revalidationCF-Cache-Status: Cloudflare edge behavior (common values:HIT,MISS,BYPASS,EXPIRED,REVALIDATED)
Example output you want for public HTML caching:
Cache-Control: public, max-age=0, s-maxage=300 CF-Cache-Status: HIT
If you see CF-Cache-Status: BYPASS, it’s often due to:
- A cookie (even a harmless analytics cookie) triggering bypass in your configuration
Cache-Control: no-store(from your app or framework defaults)- A Cloudflare rule set to bypass
7) Purge Less: Prefer Versioning Over Cache Invalidation
Purging works, but it’s a “break glass” tool. A better deployment strategy:
- All static assets are versioned (hash in filename)
- HTML is cached briefly (minutes) or not cached
- APIs are not cached unless explicitly safe
This way, you never purge for JS/CSS changes. At worst, users see the old HTML for a few minutes—then it naturally refreshes and references the new assets.
If you do need to purge (e.g., a critical content fix), purge specific URLs rather than “everything” to avoid a thundering herd on your origin.
8) Avoid the “Stale HTML + New JS” Trap
A common bug looks like this:
- Your HTML is cached for a long time
- It references
/assets/app.js(non-versioned) - You deploy new JS, but edge still serves old HTML referencing old asset paths
- Users get mismatched bundles and weird runtime errors
Fix it by doing both:
- Version assets (hash filenames)
- Keep HTML TTL shorter than assets
9) API Caching: Be Explicit (and Conservative)
Most authenticated APIs should not be cached. For public endpoints (e.g., product catalog, public blog list), caching can help—if responses don’t change per user.
Example: a public endpoint that can be cached at CDN for 60 seconds:
// Express example for a public API response app.get("/api/public/posts", async (req, res) => { res.setHeader("Cache-Control", "public, max-age=0, s-maxage=60"); const posts = await loadPublicPosts(); // your DB call res.json({ posts }); });
For anything user-specific, keep:
Cache-Control: no-store
10) A Practical Checklist You Can Apply Today
- Static assets are served from
/assets/*and have hashed filenames. - Assets return
Cache-Control: public, max-age=31536000, immutable. - HTML defaults to
Cache-Control: no-storeunless it’s clearly public. - Public HTML uses
Cache-Control: public, max-age=0, s-maxage=300(or similar). - Private routes (
/account,/admin,/checkout,/api) are alwaysno-store. - You verify behavior with
curl -Iand checkCF-Cache-Status. - You avoid purges by relying on versioned URLs; purge only for emergencies.
If you implement just the asset versioning + long-cache headers today, you’ll already remove a huge amount of load from your origin and speed up repeat visits. Then you can gradually introduce safe public HTML caching with short TTLs once you’re confident your bypass rules won’t ever cache personalized content.
Leave a Reply