Skip to main content
Technology & EngineeringEdge Computing358 lines

Edge Caching

Edge caching strategies for optimizing content delivery and reducing origin load

Quick Summary18 lines
You are an expert in edge caching strategies for building edge-first applications that minimize latency and origin load through intelligent cache management.

## Key Points

1. **Browser cache** — controlled by `Cache-Control` and `ETag` headers
2. **CDN edge cache** — the CDN's PoP stores responses based on cache headers or API directives
3. **Application cache** — in-memory or KV-based caching within edge functions
4. **Origin cache** — Redis, Varnish, or similar caches at the origin server
- **Use `s-maxage` to separate CDN and browser cache durations** — browsers often need shorter TTLs than the CDN edge.
- **Hash static asset filenames** — this enables infinite `max-age` with `immutable`, since new content gets a new URL.
- **Implement `stale-while-revalidate`** — it provides the best user experience by serving cached content instantly while refreshing in the background.
- **Normalize cache keys** — sort query parameters, strip tracking parameters, and lowercase paths to avoid duplicate cache entries.
- **Use `Vary` sparingly** — each unique combination of `Vary` header values creates a separate cache entry. Over-varying fragments the cache.
- **Set `Cache-Control: private` on authenticated responses** — prevents CDN from caching user-specific content and serving it to other users.
- **Monitor cache hit ratios** — use CDN analytics to identify low-hit-ratio paths and adjust TTLs or caching strategies accordingly.
- **Caching responses with `Set-Cookie`** — CDNs may strip cookies from cached responses or, worse, serve one user's session cookie to another. Never cache responses that set cookies.
skilldb get edge-computing-skills/Edge CachingFull skill: 358 lines
Paste into your CLAUDE.md or agent config

Edge Caching Strategies — Edge Computing

You are an expert in edge caching strategies for building edge-first applications that minimize latency and origin load through intelligent cache management.

Overview

Edge caching stores content at CDN edge nodes close to end users, reducing round trips to origin servers. Effective caching strategies combine HTTP cache headers, CDN-specific APIs, and application-level cache logic to serve content in single-digit milliseconds. The key challenge is balancing freshness against performance — serving stale content is fast but potentially incorrect, while revalidating every request eliminates the benefit of caching.

Key caching layers:

  1. Browser cache — controlled by Cache-Control and ETag headers
  2. CDN edge cache — the CDN's PoP stores responses based on cache headers or API directives
  3. Application cache — in-memory or KV-based caching within edge functions
  4. Origin cache — Redis, Varnish, or similar caches at the origin server

Core Concepts

Cache-Control Headers

// Immutable static assets (hashed filenames)
new Response(body, {
  headers: {
    "Cache-Control": "public, max-age=31536000, immutable",
  },
});

// Dynamic content that can be cached briefly
new Response(body, {
  headers: {
    "Cache-Control": "public, max-age=60, s-maxage=300",
    // max-age=60: browser caches for 60s
    // s-maxage=300: CDN caches for 300s
  },
});

// Private content (authenticated)
new Response(body, {
  headers: {
    "Cache-Control": "private, max-age=0, must-revalidate",
  },
});

// Stale-while-revalidate: serve stale content while fetching fresh in background
new Response(body, {
  headers: {
    "Cache-Control": "public, max-age=60, stale-while-revalidate=300",
  },
});

ETag and Conditional Requests

function generateETag(content: string): string {
  // Simple hash-based ETag
  let hash = 0;
  for (let i = 0; i < content.length; i++) {
    const char = content.charCodeAt(i);
    hash = ((hash << 5) - hash) + char;
    hash |= 0;
  }
  return `"${hash.toString(36)}"`;
}

async function handleWithETag(request: Request, content: string): Promise<Response> {
  const etag = generateETag(content);
  const ifNoneMatch = request.headers.get("if-none-match");

  if (ifNoneMatch === etag) {
    return new Response(null, { status: 304 });
  }

  return new Response(content, {
    headers: {
      ETag: etag,
      "Cache-Control": "public, max-age=0, must-revalidate",
    },
  });
}

Cloudflare Cache API

Workers can directly read and write to Cloudflare's edge cache:

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const cache = caches.default;
    const cacheKey = new Request(request.url, { method: "GET" });

    // Check cache
    let response = await cache.match(cacheKey);
    if (response) {
      return response;
    }

    // Fetch from origin
    response = await fetch(request);

    // Clone and cache the response
    const responseToCache = new Response(response.body, response);
    responseToCache.headers.set("Cache-Control", "public, max-age=300");

    ctx.waitUntil(cache.put(cacheKey, responseToCache.clone()));

    return responseToCache;
  },
};

Vary Header

Tell the CDN to cache different versions based on request characteristics:

new Response(body, {
  headers: {
    "Cache-Control": "public, max-age=3600",
    Vary: "Accept-Language, Accept-Encoding",
  },
});

Implementation Patterns

Tiered Cache with KV Fallback

interface Env {
  CACHE_KV: KVNamespace;
}

async function tieredCacheFetch(
  request: Request,
  env: Env,
  ctx: ExecutionContext,
  originUrl: string,
  ttlSeconds: number
): Promise<Response> {
  const cacheKey = new URL(originUrl).pathname;

  // Tier 1: Cloudflare edge cache
  const edgeCache = caches.default;
  const edgeCacheKey = new Request(`https://cache.internal${cacheKey}`);
  let response = await edgeCache.match(edgeCacheKey);
  if (response) {
    response = new Response(response.body, response);
    response.headers.set("X-Cache", "HIT-edge");
    return response;
  }

  // Tier 2: KV store (globally replicated)
  const kvValue = await env.CACHE_KV.get(cacheKey);
  if (kvValue) {
    response = new Response(kvValue, {
      headers: {
        "Content-Type": "application/json",
        "Cache-Control": `public, max-age=${ttlSeconds}`,
        "X-Cache": "HIT-kv",
      },
    });
    ctx.waitUntil(edgeCache.put(edgeCacheKey, response.clone()));
    return response;
  }

  // Tier 3: Origin
  const originResponse = await fetch(originUrl);
  const body = await originResponse.text();

  response = new Response(body, {
    headers: {
      "Content-Type": originResponse.headers.get("Content-Type") ?? "application/json",
      "Cache-Control": `public, max-age=${ttlSeconds}`,
      "X-Cache": "MISS",
    },
  });

  // Populate both cache tiers
  ctx.waitUntil(Promise.all([
    edgeCache.put(edgeCacheKey, response.clone()),
    env.CACHE_KV.put(cacheKey, body, { expirationTtl: ttlSeconds * 2 }),
  ]));

  return response;
}

Cache Purging Pattern

async function purgeCache(
  env: Env,
  ctx: ExecutionContext,
  keys: string[]
): Promise<void> {
  const edgeCache = caches.default;

  await Promise.all(
    keys.map(async (key) => {
      const cacheKey = new Request(`https://cache.internal${key}`);
      await edgeCache.delete(cacheKey);
      await env.CACHE_KV.delete(key);
    })
  );
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    if (request.method === "PURGE") {
      const authHeader = request.headers.get("Authorization");
      if (authHeader !== `Bearer ${env.PURGE_TOKEN}`) {
        return new Response("Unauthorized", { status: 401 });
      }

      const body = await request.json<{ keys: string[] }>();
      await purgeCache(env, ctx, body.keys);
      return Response.json({ purged: body.keys.length });
    }

    // ... normal request handling
    return new Response("OK");
  },
};

Stale-While-Revalidate in Application Code

When the CDN does not support stale-while-revalidate, implement it manually:

interface CachedItem<T> {
  data: T;
  timestamp: number;
  maxAge: number;
  staleWhileRevalidate: number;
}

async function swr<T>(
  kv: KVNamespace,
  key: string,
  fetcher: () => Promise<T>,
  ctx: ExecutionContext,
  maxAge = 60,
  staleWindow = 300
): Promise<T> {
  const cached = await kv.get<CachedItem<T>>(key, "json");
  const now = Date.now();

  if (cached) {
    const age = (now - cached.timestamp) / 1000;

    // Fresh: return immediately
    if (age < maxAge) {
      return cached.data;
    }

    // Stale but within revalidation window: return stale, refresh in background
    if (age < maxAge + staleWindow) {
      ctx.waitUntil(
        fetcher().then((data) =>
          kv.put(key, JSON.stringify({
            data,
            timestamp: Date.now(),
            maxAge,
            staleWhileRevalidate: staleWindow,
          }), { expirationTtl: maxAge + staleWindow + 60 })
        )
      );
      return cached.data;
    }
  }

  // No cache or fully expired: fetch synchronously
  const data = await fetcher();
  await kv.put(key, JSON.stringify({
    data,
    timestamp: Date.now(),
    maxAge,
    staleWhileRevalidate: staleWindow,
  }), { expirationTtl: maxAge + staleWindow + 60 });

  return data;
}

Cache Key Normalization

function normalizeCacheKey(request: Request): string {
  const url = new URL(request.url);

  // Sort query parameters for consistent cache keys
  const params = new URLSearchParams(url.searchParams);
  const sortedParams = new URLSearchParams([...params.entries()].sort());

  // Strip tracking parameters
  sortedParams.delete("utm_source");
  sortedParams.delete("utm_medium");
  sortedParams.delete("utm_campaign");
  sortedParams.delete("fbclid");
  sortedParams.delete("gclid");

  // Normalize pathname (lowercase, remove trailing slash)
  let pathname = url.pathname.toLowerCase();
  if (pathname.length > 1 && pathname.endsWith("/")) {
    pathname = pathname.slice(0, -1);
  }

  const queryString = sortedParams.toString();
  return `${pathname}${queryString ? "?" + queryString : ""}`;
}

Best Practices

  • Use s-maxage to separate CDN and browser cache durations — browsers often need shorter TTLs than the CDN edge.
  • Hash static asset filenames — this enables infinite max-age with immutable, since new content gets a new URL.
  • Implement stale-while-revalidate — it provides the best user experience by serving cached content instantly while refreshing in the background.
  • Normalize cache keys — sort query parameters, strip tracking parameters, and lowercase paths to avoid duplicate cache entries.
  • Use Vary sparingly — each unique combination of Vary header values creates a separate cache entry. Over-varying fragments the cache.
  • Set Cache-Control: private on authenticated responses — prevents CDN from caching user-specific content and serving it to other users.
  • Monitor cache hit ratios — use CDN analytics to identify low-hit-ratio paths and adjust TTLs or caching strategies accordingly.

Common Pitfalls

  • Caching responses with Set-Cookie — CDNs may strip cookies from cached responses or, worse, serve one user's session cookie to another. Never cache responses that set cookies.
  • Not varying on Accept-Encoding — if you serve both compressed and uncompressed responses, omitting Vary: Accept-Encoding causes clients to receive the wrong encoding.
  • Cache stampede on expiration — when a popular cache entry expires, hundreds of concurrent requests hit the origin simultaneously. Use stale-while-revalidate or locking mechanisms.
  • Forgetting to purge after deployments — stale HTML pages referencing old asset hashes cause broken experiences. Purge HTML caches on deploy.
  • Using query strings for cache busting on static assets — some CDNs ignore query strings by default. Use filename hashing instead (app.a1b2c3.js).
  • Caching error responses — a 500 or 503 from the origin gets cached and served to all users. Always check the status code before caching: only cache 200-level responses.

Core Philosophy

Caching is not an optimization — it is an architecture decision that determines your application's performance, cost, and resilience. The fastest request is one that never reaches the origin. Design your cache strategy as deliberately as your database schema: decide what is cacheable, for how long, under what conditions it is invalidated, and what happens when the cache is cold.

Stale-while-revalidate is the most user-friendly caching pattern. It serves cached content instantly (keeping the user experience fast) while refreshing in the background (keeping content fresh). The user gets the best of both worlds: speed and accuracy. Implement this pattern at the edge for content that changes periodically but does not need to be instantly fresh.

Cache keys are the hidden complexity of edge caching. Two requests to the same URL can produce different responses based on headers (Accept-Language, Accept-Encoding), cookies (auth state), query parameters (tracking parameters), and path casing. Normalize your cache keys — sort query parameters, strip tracking params, lowercase paths — to maximize cache hit rates and avoid serving the wrong content.

Anti-Patterns

  • Caching responses with Set-Cookie headers — CDNs may serve one user's session cookie to another; never cache responses that set cookies or contain user-specific data.

  • Setting Cache-Control: public on authenticated responses — this allows CDN edge nodes to serve user-specific content to other users; authenticated responses must use Cache-Control: private or no-store.

  • Using query strings for cache busting — some CDNs ignore query strings by default; use content-hashed filenames (app.a1b2c3.js) instead of app.js?v=123 for reliable cache busting.

  • Forgetting to purge HTML caches after deployments — stale HTML pages referencing old asset hashes produce broken user experiences with missing JavaScript and CSS.

  • Over-using the Vary header — each unique combination of Vary header values creates a separate cache entry; varying on too many headers fragments the cache and reduces hit rates dramatically.

Install this skill directly: skilldb add edge-computing-skills

Get CLI access →