Skip to content
Blog

Web Performance: Caching Strategies and CDNs

Practical caching strategies and CDN setup for web apps — HTTP cache headers, cache invalidation, service workers, and CDN configuration for global delivery.

Published on August 14, 2026

AI Assistant

Every asset your page loads is a network round trip, and as web.dev puts it: “Fetching resources over the network is both slow and expensive.” Large responses need many roundtrips, your page won’t render until its critical resources arrive, and every unnecessary request costs your mobile users money. Yet most developers ship with default cache headers and then wonder why Lighthouse flags them.

In this tutorial, you will learn how to layer caching across your whole stack:

  • HTTP cachingCache-Control, ETag, Last-Modified, and Vary, the browser’s first line of defense.
  • Cache invalidation — fingerprinting (hashed filenames), immutable assets, and stale-while-revalidate.
  • CDNs — edge caching, origin shielding, and TTL tuning for global delivery.
  • Service workers — precache vs. runtime caching with the Cache Storage API.

You’ll end with a working Fastify server that serves correctly-cached assets and APIs, verified with curl, plus a service worker you can drop into any app.

Prerequisites

  • Node.js 18+ and npm.
  • A basic understanding of HTTP requests/responses.
  • For the service worker section: a browser (Chrome/Firefox) and a local HTTPS or localhost server (service workers require a secure context).

How HTTP caching works

The browser’s HTTP Cache is “your first line of defense”: it’s supported in all browsers, costs nothing, and needs no app code. It is governed by response headers. The four that matter most:

HeaderWhat it does
Cache-ControlDirectives for when/how a response may be cached. The most important header.
ETagAn opaque validator string (“fingerprint”) for the response body.
Last-ModifiedA date validator; a weaker alternative to ETag.
VaryTells caches which request headers the response varies on (e.g., Vary: Accept-Encoding).

Cache-Control directives

A single Cache-Control header can combine directives:

Cache-Control: public, max-age=31536000, immutable
  • max-age=<seconds> — how long a cached response is “fresh” without asking the server.
  • public / privatepublic allows any cache (browser + shared/CDN); private means only the browser may store it (for personalized content like account data).
  • no-cache — do not use a cached copy without first revalidating with the server. Note: no-cache is not “don’t cache” — it means “cache but always check.”
  • no-store — don’t store anything at all. For sensitive data (bank balances, auth tokens).
  • immutable — the content will never change; the browser can skip revalidation even on a reload.

Validators: ETag and Last-Modified

When a response goes stale, the browser revalidates by sending the validator back:

GET /app.js
If-None-Match: "8f3a2b..."        # from a previous ETag

If the resource is unchanged, the server replies 304 Not Modified with an empty body — one tiny round trip instead of re-downloading the whole file. ETag is preferred over Last-Modified because it’s strong (body-based) and avoids second-granularity clock issues. You’ll see both in action below.

Vary

A response “varies” when its content depends on request headers. If you serve gzip/brotli based on Accept-Encoding, a cache that stored only one variant would serve the wrong bytes to the other. Vary: Accept-Encoding (and for i18n, Vary: Accept-Language) fixes that:

Vary: Accept-Encoding, Accept-Language

Cache invalidation: the fingerprinting strategy

The hard question is: how do I cache something forever without serving stale content after a deploy? web.dev’s answer: versioned URLs. If the URL changes whenever the content changes, you can cache each URL for a year with no risk.

Instead of app.js, build tools emit app-8f3a2b7c.js — the hash is derived from the file’s content. When the file changes, the hash changes, the URL changes, and old users automatically fetch the new file (the old one is just an unused cache entry). Only unversioned URLs — usually your HTML entry points — need the no-cache/revalidate treatment.

So the two golden rules:

Versioned URLs (app-8f3a2b7c.js, style-a1b2c3d4.css):
  Cache-Control: public, max-age=31536000, immutable

Unversioned URLs (/, /about):
  Cache-Control: no-cache        # always revalidate via ETag; server answers 304 when unchanged

Fingerprinting tools: Vite, webpack ([contenthash]), and Astro all do this out of the box for static builds.

stale-while-revalidate

stale-while-revalidate is a compromise for data that is mostly fine to serve slightly old: the browser serves the cached (stale) response immediately to the user, and revalidates in the background so the next request is fresh.

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

For 60 seconds the response is fresh. For the next 10 minutes it may be served stale while a background revalidation refills the cache. Great for product listings, blog feeds, or anything where a few seconds of staleness beats a loading spinner.

Worked example: caching headers in a Fastify server

Let’s put the theory into a real server. Create a small project:

npm init -y
npm install fastify @fastify/static
mkdir dist

dist/ will hold fingerprinted assets. Now server.js:

// server.js
const path = require('node:path');
const crypto = require('node:crypto');
const Fastify = require('fastify');

const fastify = Fastify({ logger: true });

// 1. Static, fingerprinted assets: cache for a year, never revalidate
fastify.register(require('@fastify/static'), {
  root: path.join(__dirname, 'dist'),
  maxAge: '1y',       // translates to max-age=31536000
  immutable: true,    // adds the immutable directive
  etag: true,         // still emit an ETag (harmless, unused while fresh)
});

// 2. HTML (unversioned): always revalidate with an ETag
fastify.get('/', (request, reply) => {
  const html = '<h1>Home</h1><script src="/app-8f3a2b7c.js"></script>';
  const etag = `"${crypto.createHash('sha1').update(html).digest('hex').slice(0, 16)}"`;
  reply.header('ETag', etag);
  reply.header('Cache-Control', 'no-cache'); // cache, but always revalidate
  if (request.headers['if-none-match'] === etag) {
    return reply.code(304).send();
  }
  reply.type('text/html').send(html);
});

// 3. API: short freshness + background revalidation, body-hashed ETag
const products = [{ id: 1, name: 'Redline' }, { id: 2, name: 'Softer' }];
const API_ETAG = `"${crypto.createHash('sha1').update(JSON.stringify(products)).digest('hex')}"`;

fastify.get('/api/products', (request, reply) => {
  reply.header('ETag', API_ETAG);
  reply.header('Cache-Control', 'public, max-age=60, stale-while-revalidate=600');
  reply.header('Vary', 'Accept-Encoding'); // content may vary by encoding
  if (request.headers['if-none-match'] === API_ETAG) {
    return reply.code(304).send();
  }
  reply.send(products);
});

// 4. Sensitive endpoint: never cache
fastify.get('/api/me', (request, reply) => {
  reply.header('Cache-Control', 'no-store, private');
  reply.send({ user: 'demo' });
});

fastify.listen({ port: 3000 });

Now verify the headers, first without any validator (a fresh browser):

curl -sI localhost:3000/app-8f3a2b7c.js
HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
ETag: "a1b2..."

Then request the API twice — the second request carries If-None-Match and should get a 304:

$ curl -sI -H 'If-None-Match: "39edb5..."' localhost:3000/api/products
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=60, stale-while-revalidate=600

Expected behavior recap: fingerprinted JS is cached for a year with zero revalidation; the HTML is always revalidated and costs a 304 (tiny) after the first visit; the API serves fresh data for a minute and tolerates up to 10 minutes of staleness; user-specific data is never stored anywhere.

CDN basics: putting a cache closer to users

A CDN is a network of edge servers that cache your responses and serve them from the geographic location nearest to the visitor. The HTTP cache headers you already set are the contract the CDN follows:

  • Edge caching — the CDN caches responses according to Cache-Control/ETag, so only the first visitor per edge actually hits your origin. Subsequent visitors get the cached copy from the edge.
  • Origin shielding — you configure the CDN’s edges to fetch from one “shield” node (rather than straight to your origin), so a cache miss anywhere collapses into a single origin request instead of N.
  • TTL tuning — CDNs let you set a CDN-level TTL that can extend (or cap) the origin’s max-age, plus a stale-while-revalidate policy at the edge so a miss never blocks the response.
  • Purge / invalidation — when you must bust a cache without changing the URL, use the CDN’s purge API or wildcard invalidation. This is the escape hatch; URL fingerprinting remains the primary strategy because it needs no coordination.

A typical Cloudflare/Fastly config just needs your origin headers right — but remember the one rule that trips everyone up: if the response varies (compression, locale), you must set Vary, or the CDN will serve the wrong variant to some users.

Service worker caching: the app-level layer

Service workers give you a cache you control with JavaScript: the Cache Storage API. There are two complementary patterns (per web.dev’s caching guidance):

  • Precaching — during install, download the “app shell” (HTML, CSS, JS) once and serve it from cache thereafter. Best for static, versioned assets and offline boot.
  • Runtime caching — as the user navigates, cache what they request (images, API responses) on the fly, using stale-while-revalidate or cache-first strategies.

Here’s a self-contained sw.js implementing both:

// sw.js
const SHELL_CACHE = 'app-shell-v1';
const PRECACHE_URLS = ['/', '/app-8f3a2b7c.js', '/style-a1b2c3d4.css'];

// Install: precache the app shell
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(SHELL_CACHE).then((cache) => cache.addAll(PRECACHE_URLS))
  );
  self.skipWaiting();
});

// Activate: delete old caches from previous deployments
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((k) => k !== SHELL_CACHE).map((k) => caches.delete(k)))
    )
  );
  self.clients.claim();
});

// Fetch: cache-first for the shell, stale-while-revalidate for images
self.addEventListener('fetch', (event) => {
  const { request } = event;

  if (request.mode === 'navigate') {
    event.respondWith(
      caches.match('/').then((cached) => cached || fetch(request))
    );
    return;
  }

  if (request.destination === 'image') {
    event.respondWith(
      caches.open('runtime-images').then(async (cache) => {
        const cached = await cache.match(request);
        const network = fetch(request).then((response) => {
          if (response.ok) cache.put(request, response.clone());
          return response;
        });
        return cached || network; // serve stale immediately, update in background
      })
    );
    return;
  }

  // Everything else: network, fall back to cache on failure
  event.respondWith(
    fetch(request).catch(() => caches.match(request))
  );
});

Register it from any page:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js');
}

The key insight: the service worker cache sits in front of the HTTP cache. The HTTP cache decides whether your service worker can even fetch from the network; the service worker decides whether the user ever needs to hit the network at all — including when they’re offline. The two layers compose: long-lived HTTP caching for bytes, service worker caching for app-level strategies and offline.

Putting It All Together

Your full caching stack, from closest to farthest:

graph LR
    %% Define nodes with icons and descriptions
    SW[Service Worker]:::nodeStyle
    BHC[Browser HTTP cache]:::nodeStyle
    CDN[CDN edge]:::nodeStyle
    ORI[Origin Server]:::nodeStyle

    %% Define connections with detailed labels
    SW -- "app strategies\n+ offline support" --> BHC
    BHC -- "Cache-Control\nETags / Vary" --> CDN
    CDN -- "geo-served\nsets headers + validates" --> ORI

Run the server, seed a fingerprinted asset, and verify every layer with curl:

echo 'console.log("hello")' > dist/app-8f3a2b7c.js
node server.js
# Layer 1 & 2: browser HTTP cache contract
curl -sI localhost:3000/app-8f3a2b7c.js | grep -i 'cache-control'
# => Cache-Control: public, max-age=31536000, immutable

# Layer 3: simulate the CDN's revalidation of the HTML
curl -sI -H 'If-None-Match: "<etag-from-first-request>"' localhost:3000/
# => HTTP/1.1 304 Not Modified

# Layer 4: install the service worker by opening the page in a browser,
# then flip to offline mode in DevTools and reload — the shell still renders.

All four layers cooperate on the same rule: versioned URLs are cached forever, unversioned URLs revalidate, and personalized/sensitive data is never cached.

Conclusion & Next Steps

You now have the complete caching playbook: Cache-Control directives and ETag validators for the HTTP layer, fingerprinting for safe long-lived caching, stale-while-revalidate for fresh-enough data, CDN edge caching with Vary correctness, and service worker precache/runtime caching for offline and instant loads.

Next steps: audit your production site with Lighthouse and the DevTools Network panel, check Vary on every compressed response, add a purge flow to your deploy pipeline for emergency invalidations, and evaluate Workbox if you’d rather not hand-roll the service worker logic.

References / Sources