Performanceintermediate

Caching Strategies for Web Apps — The Complete Guide

Master browser caching, service workers, CDN caching, cache busting, and stale-while-revalidate. Build fast, offline-capable web applications.

14 min read·Published Apr 11, 2026
performancecachingservice-workerscdn

Why Caching Matters

Caching stores copies of resources so they can be served faster on subsequent requests. Without caching, every page visit downloads every resource from scratch. With proper caching, repeat visits can be nearly instant.

Without caching:
First visit:  Server -> [200 OK, 500KB] -> Browser (1.2s)
Second visit: Server -> [200 OK, 500KB] -> Browser (1.2s)  (same cost!)
Third visit:  Server -> [200 OK, 500KB] -> Browser (1.2s)  (same cost!)

With caching:
First visit:  Server -> [200 OK, 500KB] -> Browser (1.2s) -> Cache
Second visit: Cache  -> [Instant, 0KB]  -> Browser (5ms)
Third visit:  Cache  -> [Instant, 0KB]  -> Browser (5ms)

The Caching Layers

+---------------------------------------------------------------+
|                    User's Browser                              |
|  +----------------------------------------------------------+ |
|  |  Memory Cache (fastest, cleared on tab close)            | |
|  +----------------------------------------------------------+ |
|  |  Disk Cache (persistent, shared across tabs)             | |
|  +----------------------------------------------------------+ |
|  |  Service Worker Cache (programmable, offline-capable)    | |
|  +----------------------------------------------------------+ |
+---------------------------------------------------------------+
              |
              v
+---------------------------------------------------------------+
|                    CDN Edge Server                             |
|  Cached copy of resources near the user geographically        |
+---------------------------------------------------------------+
              |
              v
+---------------------------------------------------------------+
|                    Origin Server                               |
|  The actual web server (always has the latest version)         |
+---------------------------------------------------------------+

Browser Caching: HTTP Headers

Browser caching is controlled via HTTP response headers. Two main approaches: Cache-Control (modern) and ETag (validation).

Cache-Control Header

Cache-Control directives:
+----------------------+----------------------------------------------+
| Directive            | Meaning                                      |
+----------------------+----------------------------------------------+
| public               | Any cache can store (CDN, browser)            |
| private              | Only browser can store (not CDN)              |
| no-cache             | Must revalidate with server before using      |
| no-store             | Do not cache at all                           |
| max-age=N            | Cache is fresh for N seconds                  |
| s-maxage=N           | CDN cache freshness (overrides max-age)       |
| stale-while-revalidate=N | Serve stale, revalidate in background    |
| immutable            | Will never change (skip revalidation)         |
+----------------------+----------------------------------------------+

Caching Strategy by Resource Type

// next.config.js — Configure caching headers
module.exports = {
  async headers() {
    return [
      // Static assets with content hash — cache forever
      {
        source: '/_next/static/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=31536000, immutable',
            // 1 year. File hash changes if content changes.
          },
        ],
      },
      // Images — cache for 1 day, revalidate in background
      {
        source: '/images/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=86400, stale-while-revalidate=604800',
            // Fresh for 1 day, serve stale for up to 7 days while revalidating
          },
        ],
      },
      // HTML pages — always revalidate
      {
        source: '/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=0, must-revalidate',
            // Always check with server for fresh version
          },
        ],
      },
      // API responses — short cache with revalidation
      {
        source: '/api/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'private, max-age=60, stale-while-revalidate=300',
            // Fresh for 1 min, serve stale for up to 5 min
          },
        ],
      },
    ];
  },
};

Resource-Specific Caching Strategy

+--------------------+------------------------------------+--------------------+
| Resource Type      | Cache-Control                      | Why                |
+--------------------+------------------------------------+--------------------+
| Hashed JS/CSS      | public, max-age=31536000,          | Hash changes when  |
| (_next/static/*)   | immutable                          | content changes    |
+--------------------+------------------------------------+--------------------+
| HTML pages         | public, max-age=0,                 | Content may update |
|                    | must-revalidate                    | any time           |
+--------------------+------------------------------------+--------------------+
| Images             | public, max-age=86400,             | Rarely change, but |
|                    | stale-while-revalidate=604800      | not immutable      |
+--------------------+------------------------------------+--------------------+
| Fonts              | public, max-age=31536000,          | Font files never   |
|                    | immutable                          | change             |
+--------------------+------------------------------------+--------------------+
| API responses      | private, max-age=60,               | User-specific, may |
|                    | stale-while-revalidate=300         | be stale briefly   |
+--------------------+------------------------------------+--------------------+
| User-specific data | private, no-cache                  | Must validate each |
| (profile, cart)    |                                    | request            |
+--------------------+------------------------------------+--------------------+
| Sensitive data     | private, no-store                  | Never cache (auth  |
| (tokens, PII)     |                                    | tokens, passwords) |
+--------------------+------------------------------------+--------------------+

ETag and Conditional Requests

ETags enable cache validation without re-downloading the entire resource.

First request:
Browser -> GET /api/products
Server  -> 200 OK
           ETag: "abc123"
           Cache-Control: no-cache
           [response body: 50KB]

Second request:
Browser -> GET /api/products
           If-None-Match: "abc123"
Server  -> 304 Not Modified           (no body — saves 50KB!)
           ETag: "abc123"

Third request (content changed):
Browser -> GET /api/products
           If-None-Match: "abc123"
Server  -> 200 OK                     (new content)
           ETag: "def456"
           [response body: 52KB]
// Express.js ETag implementation
const express = require('express');
const app = express();

// Express generates ETags by default
// Customize with:
app.set('etag', 'strong'); // Default: uses hash of response body

// Or generate your own ETag
app.get('/api/products', (req, res) => {
  const products = getProducts();
  const etag = generateETag(products); // Hash of content

  // Check if client's cached version matches
  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end(); // Not Modified
  }

  res.set('ETag', etag);
  res.set('Cache-Control', 'no-cache'); // Always validate
  res.json(products);
});

function generateETag(data) {
  const crypto = require('crypto');
  return '"' + crypto.createHash('md5').update(JSON.stringify(data)).digest('hex') + '"';
}

Cache Busting Strategies

When you deploy new code, users with cached old versions need to get the update. Cache busting ensures they do.

Content Hashing (Best Practice)

File-based hashing (what bundlers do automatically):
app.js       -> app.a1b2c3d4.js
styles.css   -> styles.e5f6g7h8.css
vendor.js    -> vendor.i9j0k1l2.js

When you change app.js:
app.a1b2c3d4.js -> app.m3n4o5p6.js (new hash = new URL = new download)
vendor.i9j0k1l2.js -> vendor.i9j0k1l2.js (unchanged = same URL = cached)
// next.config.js — Next.js does this automatically
// All files in /_next/static/ have content hashes

// For custom assets, use manual hashing:
const crypto = require('crypto');
const fs = require('fs');

function getFileHash(filePath) {
  const content = fs.readFileSync(filePath);
  return crypto.createHash('md5').update(content).digest('hex').slice(0, 8);
}

// In your HTML template:
const cssHash = getFileHash('./public/styles/custom.css');
// <link rel="stylesheet" href={`/styles/custom.css?v=${cssHash}`} />

Query String Busting (Simpler, Less Reliable)

<!-- Version-based -->
<link rel="stylesheet" href="/styles.css?v=2.1.0" />
<script src="/app.js?v=2.1.0"></script>

<!-- Timestamp-based -->
<link rel="stylesheet" href="/styles.css?t=1709312400" />

<!-- WARNING: Some CDNs ignore query strings for caching -->
<!-- Content hashing is more reliable -->

Deployment Cache Invalidation

// scripts/invalidate-cache.js
// Purge CDN cache after deploy

// Cloudflare
async function purgeCloudflareCache() {
  await fetch(
    `https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${CF_TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        // Purge specific files
        files: [
          'https://example.com/',
          'https://example.com/about',
        ],
        // Or purge everything
        // purge_everything: true,
      }),
    }
  );
}

// Vercel (automatic on deploy)
// Next.js on Vercel automatically invalidates:
// - HTML pages: always fresh (revalidated on each request)
// - Static assets: immutable (new hash on new build)
// - ISR pages: revalidated based on revalidate timer

Service Workers and Offline Capability

Service workers act as a programmable network proxy between the browser and the server. They enable offline support, background sync, and custom caching strategies.

Service Worker Lifecycle

+-------------------+    +-------------------+    +-------------------+
|    Installing     | -> |    Waiting        | -> |    Active         |
| (downloading      |    | (previous worker  |    | (controls pages,  |
|  cached assets)   |    |  still active)    |    |  intercepts       |
+-------------------+    +-------------------+    |  fetch requests)  |
                                                  +-------------------+

Registering a Service Worker

// pages/_app.tsx or layout component
import { useEffect } from 'react';

export default function App({ Component, pageProps }) {
  useEffect(() => {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker
        .register('/sw.js')
        .then((reg) => console.log('SW registered:', reg.scope))
        .catch((err) => console.log('SW registration failed:', err));
    }
  }, []);

  return <Component {...pageProps} />;
}

Cache-First Strategy (Offline-First)

// public/sw.js
const CACHE_NAME = 'app-v1';
const STATIC_ASSETS = [
  '/',
  '/offline.html',
  '/styles/main.css',
  '/js/app.js',
  '/images/logo.svg',
];

// Install: Pre-cache static assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(STATIC_ASSETS);
    })
  );
  self.skipWaiting(); // Activate immediately
});

// Activate: Clean up old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) => {
      return Promise.all(
        keys
          .filter((key) => key !== CACHE_NAME)
          .map((key) => caches.delete(key))
      );
    })
  );
  self.clients.claim(); // Take control of all pages
});

// Fetch: Cache-first, fall back to network
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      if (cached) return cached;

      return fetch(event.request)
        .then((response) => {
          // Cache successful responses for future use
          if (response.ok) {
            const clone = response.clone();
            caches.open(CACHE_NAME).then((cache) => {
              cache.put(event.request, clone);
            });
          }
          return response;
        })
        .catch(() => {
          // Offline fallback for navigation requests
          if (event.request.mode === 'navigate') {
            return caches.match('/offline.html');
          }
        });
    })
  );
});

Network-First Strategy (Fresh Content Priority)

// For API calls and dynamic content
self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then((response) => {
          // Cache the fresh response
          const clone = response.clone();
          caches.open('api-cache').then((cache) => {
            cache.put(event.request, clone);
          });
          return response;
        })
        .catch(() => {
          // Network failed — serve from cache
          return caches.match(event.request);
        })
    );
  }
});

Stale-While-Revalidate in Service Worker

// Serve cached version immediately, fetch update in background
self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/content/')) {
    event.respondWith(
      caches.open('content-cache').then((cache) => {
        return cache.match(event.request).then((cached) => {
          // Always fetch fresh version in background
          const fetchPromise = fetch(event.request).then((networkResponse) => {
            cache.put(event.request, networkResponse.clone());
            return networkResponse;
          });

          // Return cached immediately, or wait for network
          return cached || fetchPromise;
        });
      })
    );
  }
});

Caching Strategy Decision Tree

What type of content?
|
+-- Static assets (JS, CSS, fonts, images)
|   -> Cache-first with long max-age
|   -> Bust cache with content hash
|
+-- HTML pages
|   -> Network-first (always try fresh)
|   -> Fall back to cached for offline
|
+-- API data
|   +-- Real-time data (chat, stock prices)
|   |   -> Network-only (no caching)
|   |
|   +-- Semi-fresh data (product list, articles)
|   |   -> Stale-while-revalidate
|   |
|   +-- Rarely changing data (config, categories)
|       -> Cache-first, revalidate periodically
|
+-- User-specific data (profile, settings)
    -> Network-first, private cache only

CDN Caching

A CDN caches your resources on edge servers around the world, reducing latency for users far from your origin server.

CDN Cache Headers

// Different cache durations for CDN vs browser
// s-maxage controls CDN, max-age controls browser

module.exports = {
  async headers() {
    return [
      {
        // CDN caches for 1 hour, browser caches for 0 seconds
        // Browser always asks CDN, CDN usually has it cached
        source: '/blog/:slug',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400',
          },
        ],
      },
    ];
  },
};

CDN Cache Flow

First request (cache MISS):
User -> CDN Edge (no cache) -> Origin Server -> CDN Edge (stores copy) -> User
Total: Origin response time + CDN overhead

Subsequent requests (cache HIT):
User -> CDN Edge (cached!) -> User
Total: ~10-50ms regardless of origin distance

Cache expired:
User -> CDN Edge (stale) -> User (gets stale)
                         -> Origin Server (background revalidation)
Next request gets fresh version.

Surrogate Keys for Targeted Invalidation

// Tag responses with surrogate keys for precise cache invalidation
app.get('/api/products/:id', (req, res) => {
  const product = getProduct(req.params.id);

  res.set('Surrogate-Key', `product-${product.id} category-${product.categoryId}`);
  res.set('Cache-Control', 'public, s-maxage=3600');
  res.json(product);
});

// When a product updates, purge only related caches
async function onProductUpdate(productId) {
  await purgeByTag(`product-${productId}`);
  // Only pages/APIs tagged with this product are invalidated
  // Everything else stays cached
}

API Response Caching

In-Memory Client Cache

// Simple client-side cache for API responses
class APICache {
  constructor(defaultTTL = 60000) {
    this.cache = new Map();
    this.defaultTTL = defaultTTL;
  }

  async fetch(url, options = {}) {
    const key = `${options.method || 'GET'}:${url}`;
    const cached = this.cache.get(key);

    if (cached && Date.now() < cached.expiry) {
      return cached.data;
    }

    const response = await fetch(url, options);
    const data = await response.json();

    this.cache.set(key, {
      data,
      expiry: Date.now() + (options.ttl || this.defaultTTL),
    });

    return data;
  }

  invalidate(url) {
    const key = `GET:${url}`;
    this.cache.delete(key);
  }

  clear() {
    this.cache.clear();
  }
}

const apiCache = new APICache(30000); // 30 second default TTL

// Usage
const products = await apiCache.fetch('/api/products');        // Network
const products2 = await apiCache.fetch('/api/products');       // Cached!
apiCache.invalidate('/api/products');                          // Clear
const products3 = await apiCache.fetch('/api/products');       // Network again

React Query / SWR Caching

// SWR — stale-while-revalidate built in
import useSWR from 'swr';

const fetcher = (url) => fetch(url).then((r) => r.json());

function ProductList() {
  const { data, error, isLoading, mutate } = useSWR('/api/products', fetcher, {
    revalidateOnFocus: true,      // Refresh when tab regains focus
    revalidateOnReconnect: true,  // Refresh when network reconnects
    dedupingInterval: 5000,       // Deduplicate requests within 5s
    refreshInterval: 30000,       // Poll every 30s
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error loading products</p>;

  return (
    <ul>
      {data.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}
// React Query (TanStack Query)
import { useQuery, useQueryClient } from '@tanstack/react-query';

function ProductList() {
  const { data, isLoading } = useQuery({
    queryKey: ['products'],
    queryFn: () => fetch('/api/products').then((r) => r.json()),
    staleTime: 30000,        // Consider data fresh for 30 seconds
    gcTime: 300000,          // Keep in cache for 5 minutes (previously cacheTime)
    refetchOnWindowFocus: true,
  });

  if (isLoading) return <p>Loading...</p>;

  return (
    <ul>
      {data.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

// Invalidate cache when data changes
function useUpdateProduct() {
  const queryClient = useQueryClient();

  return async (productId, updates) => {
    await fetch(`/api/products/${productId}`, {
      method: 'PATCH',
      body: JSON.stringify(updates),
    });

    // Invalidate and refetch product list
    queryClient.invalidateQueries({ queryKey: ['products'] });
  };
}

Stale-While-Revalidate Pattern

This pattern serves cached (possibly stale) content immediately while fetching fresh content in the background.

HTTP Header Version

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

Timeline:
0-60s:    Fresh. Serve from cache directly.
60-3660s: Stale. Serve from cache, but revalidate in background.
3660s+:   Too stale. Must wait for network response.

+--------+---------+------------------------------------------+
| 0-60s  | FRESH   | Serve cached instantly                   |
| 60s-1h | STALE   | Serve cached + background revalidation   |
| 1h+    | EXPIRED | Must fetch from origin                   |
+--------+---------+------------------------------------------+

JavaScript Implementation

// Generic stale-while-revalidate wrapper
function createSWRFetcher(keyFn, fetchFn, options = {}) {
  const { maxAge = 60000, staleAge = 3600000 } = options;
  const cache = new Map();

  return async function (...args) {
    const key = keyFn(...args);
    const cached = cache.get(key);
    const now = Date.now();

    // Fresh — return immediately
    if (cached && now - cached.timestamp < maxAge) {
      return cached.data;
    }

    // Stale — return cached, revalidate in background
    if (cached && now - cached.timestamp < staleAge) {
      // Background revalidation (don't await)
      fetchFn(...args).then((data) => {
        cache.set(key, { data, timestamp: Date.now() });
      });
      return cached.data;
    }

    // Expired or missing — must wait for network
    const data = await fetchFn(...args);
    cache.set(key, { data, timestamp: Date.now() });
    return data;
  };
}

// Usage
const getProducts = createSWRFetcher(
  () => 'products',
  () => fetch('/api/products').then((r) => r.json()),
  { maxAge: 30000, staleAge: 300000 }
);

const products = await getProducts(); // Network (first call)
const products2 = await getProducts(); // Cached (instant)
// After 30s:
const products3 = await getProducts(); // Stale (instant + background refresh)

Caching Anti-Patterns

Common Mistakes

// MISTAKE 1: Caching user-specific data publicly
// Bad
res.set('Cache-Control', 'public, max-age=3600');
res.json({ user: { name: 'Alice', email: '[email protected]' } });
// CDN now serves Alice's data to everyone!

// Good
res.set('Cache-Control', 'private, no-cache');
res.json({ user: { name: 'Alice', email: '[email protected]' } });

// MISTAKE 2: Caching without a bust mechanism
// Bad: Cache forever with no hash
res.set('Cache-Control', 'public, max-age=31536000');
// If you deploy new CSS, users stuck with old version for a year

// Good: Use content hashing
// styles.a1b2c3d4.css -> Cache-Control: immutable
// New deploy: styles.e5f6g7h8.css (new URL, new download)

// MISTAKE 3: Over-caching API responses
// Bad
res.set('Cache-Control', 'public, max-age=86400'); // 1 day cache on a product price
// Price changes? Users see stale price for up to 24 hours

// Good
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
// Fresh for 1 minute, stale-but-usable for 5 minutes

// MISTAKE 4: No Vary header for content negotiation
// Bad: Different content for same URL (e.g., WebP vs JPEG based on Accept header)
// CDN caches one format and serves it to all browsers

// Good
res.set('Vary', 'Accept'); // Cache separate versions per Accept header

Key Takeaways

  • Use Cache-Control: public, max-age=31536000, immutable for hashed static assets
  • Use Cache-Control: public, max-age=0, must-revalidate for HTML pages
  • Use stale-while-revalidate to serve fast while keeping content fresh
  • ETags enable cache validation without re-downloading unchanged resources
  • Content hashing is the most reliable cache busting strategy
  • Service workers enable offline-first experiences and programmable caching
  • CDN caching reduces latency by serving from edge locations
  • s-maxage controls CDN cache separately from browser cache (max-age)
  • Never cache user-specific or sensitive data with public headers
  • React Query and SWR implement stale-while-revalidate patterns for API data
  • Always have a cache invalidation strategy before setting long cache durations

Found this helpful?

Support devsofus — help us keep creating free dev guides.

Related Articles