A

Caching Strategies

performancecachingcache-controlcdnredishttp-headers

Caching Strategies

Caching eliminates repeated work. A well-designed caching strategy means returning visitors load instantly, CDN edges serve content without hitting your origin, and your database handles a fraction of the requests it otherwise would.

This guide covers the three layers of web caching: browser, CDN, and server-side.

Browser Caching

Cache-Control Header

The Cache-Control header tells browsers how to cache responses:

Cache-Control: max-age=31536000, immutable

Common directives:

Directive Meaning
max-age=N Cache for N seconds
no-cache Cache, but revalidate every time
no-store Never cache (sensitive data)
private Only browser can cache (not CDN)
public Both browser and CDN can cache
immutable Never revalidate (for fingerprinted assets)
must-revalidate Once stale, must revalidate
stale-while-revalidate=N Serve stale for N seconds while fetching fresh

Caching Strategies by Resource Type

Fingerprinted assets (CSS, JS, images with hash in filename):

Cache-Control: public, max-age=31536000, immutable

Files like app.8f3d2a.js never change—the filename changes when content changes. Cache forever.

HTML pages (content changes):

Cache-Control: public, max-age=0, must-revalidate

Or for pages that update infrequently:

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

This serves cached content for 1 hour, then for the next 24 hours serves stale while fetching fresh in the background.

API responses (dynamic data):

Cache-Control: private, max-age=60

For user-specific data. Never cache on CDN.

Sensitive data:

Cache-Control: no-store

Auth tokens, personal info, etc. Don't cache anywhere.

Configuring Cache Headers

Nginx:

# Fingerprinted assets (immutable)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
    if ($uri ~* "\.[a-f0-9]{8,}\.") {
        expires max;
        add_header Cache-Control "public, immutable";
    }
}

# Regular static files
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
    expires 1y;
    add_header Cache-Control "public";
}

# HTML
location ~* \.html$ {
    expires 0;
    add_header Cache-Control "no-cache, must-revalidate";
}

Express.js:

import express from 'express';

const app = express();

// Fingerprinted assets
app.use(
  '/assets',
  express.static('dist/assets', {
    maxAge: '1y',
    immutable: true,
  })
);

// Regular static files
app.use(
  express.static('public', {
    maxAge: '1d',
    setHeaders: (res, path) => {
      if (path.endsWith('.html')) {
        res.setHeader('Cache-Control', 'no-cache, must-revalidate');
      }
    },
  })
);

Astro (via astro.config.mjs with adapter):

// Headers set at CDN/server level, not in Astro config
// See your deployment platform's documentation

ETag Validation

ETags allow the browser to ask "has this changed?" without re-downloading:

# Initial response
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Content-Length: 12345

# Subsequent request
GET /page.html HTTP/1.1
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

# If unchanged
HTTP/1.1 304 Not Modified

The 304 response is tiny—no body is sent. This is useful for frequently-changing content where you want validation but minimal transfer.

Most servers generate ETags automatically. For static files, it's typically based on file modification time and size.

Cache Busting

When content changes, you need to bust the cache. Two approaches:

Fingerprinted filenames (recommended):

<!-- Hash changes when content changes -->
<script src="/app.8f3d2a1b.js"></script>
<link rel="stylesheet" href="/styles.c4f2e9.css">

Bundlers like Vite generate these automatically:

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        entryFileNames: 'assets/[name].[hash].js',
        chunkFileNames: 'assets/[name].[hash].js',
        assetFileNames: 'assets/[name].[hash].[ext]',
      },
    },
  },
});

Query strings (works but less elegant):

<script src="/app.js?v=1.2.3"></script>

Some CDNs ignore query strings by default, making this less reliable.

CDN Caching

CDNs cache content at edge locations worldwide, reducing latency and origin load.

How CDN Caching Works

User → Edge (Dallas) → Origin (Virginia)
              ↓
         [Cache Miss: Fetch from origin, store]
              ↓
         [Cache Hit: Serve directly, skip origin]

Subsequent requests from Dallas-area users hit the edge cache, never reaching your origin.

CDN Cache Headers

CDNs respect Cache-Control headers, but often support additional directives:

Cloudflare:

# Control CDN separately from browser
Cache-Control: public, max-age=60
CDN-Cache-Control: max-age=3600

Vercel:

# Stale-while-revalidate at edge
Cache-Control: public, max-age=60, stale-while-revalidate=3600
Vercel-CDN-Cache-Control: max-age=3600

AWS CloudFront:

# Uses standard Cache-Control
# Configure behaviors in CloudFront console

Surrogate Keys for Selective Purging

When content changes, you need to invalidate specific cached items:

# Tag responses with surrogate keys
Surrogate-Key: article-123 category-tech homepage

Then purge by key:

# Fastly API
curl -X POST "https://api.fastly.com/service/$SERVICE_ID/purge/article-123" \
  -H "Fastly-Key: $API_KEY"

This invalidates all cached responses tagged with article-123 without affecting other content.

Edge Caching Patterns

Cache HTML at the edge:

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

For 1 minute, serve the cached version. For the next 24 hours, serve stale while fetching fresh. After that, block until fresh content arrives.

Bypass cache for authenticated users:

// Cloudflare Worker
export default {
  async fetch(request) {
    // Skip cache for authenticated requests
    if (request.headers.get('Authorization')) {
      return fetch(request, { cf: { cacheEverything: false } });
    }

    // Cache public content
    return fetch(request, {
      cf: {
        cacheEverything: true,
        cacheTtl: 3600,
      },
    });
  },
};

Vary header for conditional caching:

# Cache different versions based on request headers
Vary: Accept-Encoding, Accept-Language

This tells the CDN to maintain separate cached versions for different Accept-Language values (for example).

Gotcha: Vary: * or Vary: Cookie often disables caching entirely.

Cache Invalidation

Path-based purging:

# Cloudflare
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
  -H "Authorization: Bearer $API_TOKEN" \
  -d '{"files": ["https://example.com/page.html"]}'

# Vercel
vercel --scope=team purge https://example.com/page.html

Purge everything (nuclear option):

# Cloudflare
curl -X POST ".../purge_cache" -d '{"purge_everything": true}'

Use sparingly—this creates a thundering herd on your origin.

Soft purge (Fastly):

# Mark as stale but still serve while fetching fresh
curl -X POST "https://api.fastly.com/service/$SERVICE_ID/purge/all" \
  -H "Fastly-Soft-Purge: 1"

Server-Side Caching

Caching at the application level prevents repeated computation and database queries.

In-Memory Caching

For single-server deployments or request-scoped data:

// Simple in-memory cache with TTL
class MemoryCache<T> {
  private cache = new Map<string, { value: T; expires: number }>();

  get(key: string): T | undefined {
    const item = this.cache.get(key);
    if (!item) return undefined;
    if (Date.now() > item.expires) {
      this.cache.delete(key);
      return undefined;
    }
    return item.value;
  }

  set(key: string, value: T, ttlMs: number): void {
    this.cache.set(key, {
      value,
      expires: Date.now() + ttlMs,
    });
  }
}

const cache = new MemoryCache<string>();

async function getExpensiveData(id: string): Promise<Data> {
  const cached = cache.get(`data:${id}`);
  if (cached) return JSON.parse(cached);

  const data = await fetchFromDatabase(id);
  cache.set(`data:${id}`, JSON.stringify(data), 60_000); // 1 minute
  return data;
}

Gotcha: In-memory caches don't share across processes/servers. Use Redis for distributed caching.

Redis Caching

Redis provides distributed, persistent caching across multiple servers:

// lib/cache.ts
import { Redis } from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

export async function cached<T>(
  key: string,
  fn: () => Promise<T>,
  ttlSeconds = 300
): Promise<T> {
  // Try cache first
  const cached = await redis.get(key);
  if (cached) {
    return JSON.parse(cached);
  }

  // Compute and cache
  const result = await fn();
  await redis.setex(key, ttlSeconds, JSON.stringify(result));
  return result;
}

// Usage
const user = await cached(
  `user:${userId}`,
  () => db.users.findUnique({ where: { id: userId } }),
  600 // 10 minutes
);

Cache invalidation patterns:

// Invalidate on update
async function updateUser(id: string, data: Partial<User>) {
  await db.users.update({ where: { id }, data });
  await redis.del(`user:${id}`);
}

// Invalidate by pattern (use with caution)
async function invalidateUserCaches(userId: string) {
  const keys = await redis.keys(`user:${userId}:*`);
  if (keys.length > 0) {
    await redis.del(...keys);
  }
}

See the Redis article for detailed Redis usage patterns.

Full Page Caching

Cache entire HTML responses for anonymous users:

// middleware/pageCache.ts
import { Redis } from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

export async function pageCacheMiddleware(req, res, next) {
  // Skip for authenticated users
  if (req.user) {
    return next();
  }

  const cacheKey = `page:${req.url}`;
  const cached = await redis.get(cacheKey);

  if (cached) {
    res.setHeader('X-Cache', 'HIT');
    res.setHeader('Content-Type', 'text/html');
    return res.send(cached);
  }

  // Capture the response
  const originalSend = res.send.bind(res);
  res.send = (body) => {
    if (res.statusCode === 200 && typeof body === 'string') {
      redis.setex(cacheKey, 300, body); // 5 minutes
    }
    res.setHeader('X-Cache', 'MISS');
    return originalSend(body);
  };

  next();
}

ISR: Incremental Static Regeneration

Next.js and similar frameworks can revalidate static pages on-demand:

// pages/posts/[slug].tsx (Next.js Pages Router)
export async function getStaticProps({ params }) {
  const post = await getPost(params.slug);
  return {
    props: { post },
    revalidate: 60, // Regenerate every 60 seconds
  };
}

export async function getStaticPaths() {
  const posts = await getAllPosts();
  return {
    paths: posts.map((p) => ({ params: { slug: p.slug } })),
    fallback: 'blocking', // Generate missing pages on-demand
  };
}

On-demand revalidation (when content changes):

// pages/api/revalidate.ts
export default async function handler(req, res) {
  const { slug, secret } = req.query;

  if (secret !== process.env.REVALIDATION_SECRET) {
    return res.status(401).json({ message: 'Invalid token' });
  }

  try {
    await res.revalidate(`/posts/${slug}`);
    return res.json({ revalidated: true });
  } catch (err) {
    return res.status(500).send('Error revalidating');
  }
}

Data Caching Patterns

Read-through cache:

async function getProduct(id: string): Promise<Product> {
  const cacheKey = `product:${id}`;
  const cached = await redis.get(cacheKey);

  if (cached) {
    return JSON.parse(cached);
  }

  // Cache miss: fetch and store
  const product = await db.products.findUnique({ where: { id } });
  await redis.setex(cacheKey, 3600, JSON.stringify(product));
  return product;
}

Write-through cache:

async function updateProduct(id: string, data: Partial<Product>) {
  // Update database first
  const product = await db.products.update({
    where: { id },
    data,
  });

  // Then update cache
  await redis.setex(`product:${id}`, 3600, JSON.stringify(product));

  return product;
}

Cache-aside (lazy loading):

async function getProducts(categoryId: string): Promise<Product[]> {
  const cacheKey = `products:category:${categoryId}`;

  // Try cache
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  // Fallback to database
  const products = await db.products.findMany({
    where: { categoryId },
  });

  // Background cache update (don't await)
  redis.setex(cacheKey, 300, JSON.stringify(products)).catch(console.error);

  return products;
}

Cache Warming

Pre-populate caches to avoid cold-start latency:

// scripts/warm-cache.ts
async function warmCache() {
  const popularPaths = [
    '/',
    '/products',
    '/about',
    ...await getTopProducts(100).then(ps => ps.map(p => `/products/${p.slug}`)),
  ];

  for (const path of popularPaths) {
    await fetch(`https://example.com${path}`);
    console.log(`Warmed: ${path}`);
  }
}

Run after deployments or cache purges.

Debugging Cache Issues

Check Cache Headers

# View response headers
curl -I https://example.com/page.html

# Check CDN cache status
curl -I https://example.com/page.html | grep -i "x-cache\|cf-cache-status\|age"

Common CDN status headers:

Header Values
X-Cache HIT, MISS, EXPIRED
CF-Cache-Status (Cloudflare) HIT, MISS, EXPIRED, DYNAMIC, BYPASS
Age Seconds since cached
X-Served-By Which edge served the request

Cache Debugging Checklist

  1. Is the Cache-Control header correct? Check your server config
  2. Is Vary too broad? Vary: Cookie often disables caching
  3. Are query strings being cached? Some CDNs ignore them by default
  4. Is the CDN respecting headers? Some CDNs have their own caching rules
  5. Is there a Set-Cookie header? This often prevents caching

Common Pitfalls

Set-Cookie preventing caching:

# Response with Set-Cookie is typically not cached
HTTP/1.1 200 OK
Set-Cookie: session=abc123
Cache-Control: public, max-age=3600

Solution: Set cookies on a different path, or use JavaScript for non-essential cookies.

Vary: Accept-Encoding issues:

# This is fine - most CDNs handle it
Vary: Accept-Encoding

# This is problematic - creates many cache variants
Vary: Accept-Encoding, User-Agent

Private content cached publicly:

// ❌ Dangerous: user data cached at CDN
res.setHeader('Cache-Control', 'public, max-age=3600');
res.json({ user: req.user });

// ✅ Safe: private cache only
res.setHeader('Cache-Control', 'private, max-age=60');
res.json({ user: req.user });

See Also

Last updated: March 23, 2026