Cloudflare CDN in Practice: Cache Smarter, Ship Faster (Hands-On)

Cloudflare CDN in Practice: Cache Smarter, Ship Faster (Hands-On)

If you’ve put Cloudflare in front of a web app and assumed “CDN = magically fast,” you’ve probably also seen confusing behavior: HTML that won’t update, assets that don’t cache, or an origin that still gets hammered. This guide shows a practical, junior-friendly setup for caching static assets aggressively, keeping HTML fresh, and using a tiny edge Worker when you need extra control.

The goal is simple:

  • Cache static assets (CSS/JS/images/fonts) for a long time.
  • Keep HTML dynamic (or lightly cached) to avoid stale pages.
  • Reduce origin load without breaking deployments.

1) Know what Cloudflare caches (and what it doesn’t)

By default, Cloudflare caches static file types (images, CSS, JS, etc.) when the response is cacheable. It generally does not cache HTML unless you add rules (Cache Rules / Page Rules) or use a Worker to override behavior.

Two key concepts:

  • Browser cache: Controlled by Cache-Control headers. This affects the user’s browser.
  • CDN (edge) cache: Cloudflare’s cache. You can influence it with headers and rules, but Cloudflare can also override via settings/rules.

When debugging, you’ll care about these headers:

  • Cache-Control (your origin’s intent)
  • ETag/Last-Modified (revalidation)
  • CF-Cache-Status (Cloudflare result: HIT/MISS/BYPASS/EXPIRED)

2) Set “boring but correct” caching headers on your origin

Cloudflare is easier when your origin sends good headers. A common approach:

  • Static assets: Cache-Control: public, max-age=31536000, immutable
  • HTML pages: Cache-Control: no-store (or short caching like max-age=0 with revalidation)

Nginx example (static assets long-lived; HTML not stored):

# /etc/nginx/sites-available/app.conf server { listen 80; server_name example.com; root /var/www/app; # HTML: avoid accidental caching location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-store" always; } # Versioned static assets (recommended: /assets/app.a1b2c3.js) location ~* \.(?:css|js|png|jpg|jpeg|gif|webp|svg|ico|woff2?)$ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable" always; try_files $uri =404; } } 

Why “immutable”? It tells browsers “don’t even revalidate,” which is great if your filenames change when content changes (hashing).

3) Use asset versioning so you can cache “forever” safely

Long caching only works if deployments don’t reuse the same URL for different content. Use hashed filenames (most bundlers do this):

  • Vite: assets/index.8d9f3c.js
  • Webpack: app.[contenthash].js
  • Next.js: hashed build assets out of the box

If your app serves /app.js and you overwrite it every deploy, you’ll fight stale caches forever. Fix versioning first.

4) Verify caching with curl (fast feedback loop)

After Cloudflare is enabled for your domain, test a static asset:

curl -I https://example.com/assets/app.8d9f3c.js

Look for:

  • cache-control: public, max-age=31536000, immutable
  • cf-cache-status: HIT (after the first request)

Run it twice. First is often MISS, second should become HIT if caching is working.

5) Keep HTML fresh (and stop accidental CDN caching)

For server-rendered pages or SPA entry HTML (/index.html), a safe default is no-store. That ensures both browsers and intermediaries don’t cache.

If you want a middle ground (helpful for high-traffic marketing pages): cache briefly and revalidate:

Cache-Control: public, max-age=60, stale-while-revalidate=300

This lets the CDN serve a slightly stale page while fetching a fresh one in the background.

6) Add a Cloudflare Worker to “pin” cache behavior (optional but powerful)

Sometimes you can’t easily change origin headers (legacy app, multiple services, etc.). A Worker can enforce simple rules at the edge:

  • Never cache HTML
  • Cache /assets/* aggressively at the edge
  • Add a debug header so you can see what happened

Cloudflare Worker (JavaScript):

export default { async fetch(request, env, ctx) { const url = new URL(request.url); // Example rule: aggressively cache hashed assets const isAsset = url.pathname.startsWith("/assets/") || url.pathname.match(/\.(css|js|png|jpg|jpeg|gif|webp|svg|ico|woff2?)$/); // Never cache HTML / app shell by default const isHtml = url.pathname === "/" || url.pathname.endsWith(".html"); if (isAsset) { // Cache at Cloudflare edge for 1 year const cacheTtlSeconds = 60 * 60 * 24 * 365; const response = await fetch(request, { cf: { cacheEverything: true, cacheTtl: cacheTtlSeconds, }, }); const newHeaders = new Headers(response.headers); newHeaders.set("Cache-Control", "public, max-age=31536000, immutable"); newHeaders.set("X-Edge-Cache", "asset-long"); return new Response(response.body, { status: response.status, headers: newHeaders }); } if (isHtml) { const response = await fetch(request, { cf: { cacheEverything: false }, }); const newHeaders = new Headers(response.headers); newHeaders.set("Cache-Control", "no-store"); newHeaders.set("X-Edge-Cache", "html-no-store"); return new Response(response.body, { status: response.status, headers: newHeaders }); } // Default passthrough const response = await fetch(request); const newHeaders = new Headers(response.headers); newHeaders.set("X-Edge-Cache", "passthrough"); return new Response(response.body, { status: response.status, headers: newHeaders }); }, }; 

Now requests will include X-Edge-Cache so you can see which branch ran, and Cloudflare caching is explicitly controlled for assets.

7) Handle cache busting and “why is my site still stale?”

Common causes of stale content:

  • Non-versioned assets cached too long (fix by hashing filenames)
  • HTML cached somewhere (browser, service worker, edge rules)
  • Service Worker serving old files (check your PWA setup)

Fast troubleshooting checklist:

  • Check CF-Cache-Status with curl -I
  • Hard refresh in browser (or open in a private window)
  • Confirm your HTML response has Cache-Control: no-store
  • Confirm assets are hashed and have long cache headers

If you must purge cache after a deploy (not ideal, but sometimes necessary), set up a deploy step that hits Cloudflare’s purge endpoint. Many CI tools have plugins/actions for this, but the long-term fix is still: versioned assets + correct headers.

8) One practical “good default” recipe

If you want a setup that works for most modern web apps:

  • HTML: Cache-Control: no-store
  • Static assets (hashed): Cache-Control: public, max-age=31536000, immutable
  • Cloudflare: enable compression (Brotli), HTTP/2 or HTTP/3, and keep “cache everything” off unless you know why you need it
  • Optional: Worker to enforce the above if your origin can’t

9) Quick mini-lab: prove the improvement

Do this on a staging domain:

  • Pick one JS bundle URL (hashed) and one HTML page.
  • Hit each twice and compare headers.
# Asset (should become HIT) curl -I https://example.com/assets/app.8d9f3c.js curl -I https://example.com/assets/app.8d9f3c.js # HTML (should stay BYPASS or MISS, but not cache as HIT) curl -I https://example.com/ curl -I https://example.com/

Expected outcome:

  • Asset shows cf-cache-status: HIT on the second request and long cache-control.
  • HTML shows cache-control: no-store and does not stick as a long-lived HIT.

Wrap-up

Cloudflare CDN performance isn’t magic—it’s mostly cache rules + good headers + versioned assets. Start by making static assets cache “forever” safely (hashed filenames), keep HTML fresh with no-store (or short cache + revalidation), then add a Worker only if you need edge enforcement or more advanced behavior.

If you want, I can write a follow-up hands-on piece on one focused subtopic (e.g., “Edge image optimization + responsive images,” or “Full-page caching for marketing pages without stale deploys”)—still keeping it junior-friendly and code-heavy.


Leave a Reply

Your email address will not be published. Required fields are marked *