Fun With HTTP Caching 27 Dec 2025

The two fun problems in computer science: cache invalidation, naming things, and off-by-one errors. Today I want to talk about the first one.

I've been working on Epithet, an SSH certificate authority Really, an agent, a CA, and policy server that makes certificate-based authentication easy. Part of the system involves a "discovery" endpoint where clients learn which hosts the CA handles. The question: how do you cache this efficiently while still allowing updates to propagate?

The policy server component provides a discovery endpoint for this, which serves a simple JSON document:

{"matchPatterns": ["*.example.com", "prod-*"]}

This content rarely changes, only when you add a new host pattern to your policy, generally. But when it does change, you want clients to pick it up reasonably quickly. My first implementation used aggressive caching:

w.Header().Set("Cache-Control", "max-age=31536000, immutable")

Cache it forever! The URL is content-addressed (/d/{hash}), so if the content changes, the hash changes, and you get a new URL. Problem solved, right?

Not quite. The client learns the discovery URL from a Link header in other responses:

Link: </d/abc123>; rel="discovery"

But the client caches this URL. If the server starts returning a different URL in the Link header, the client won't notice until... when exactly?

The discovery document at /d/abc123 is immutable and cached forever. The client has no reason to re-fetch it. And it has no reason to make other requests that would reveal the new Link header. We've created an immortal cache entry.

The obvious fix: use ETag and If-None-Match for cache revalidation.

w.Header().Set("Cache-Control", "max-age=300")  // 5 minutes
w.Header().Set("ETag", `"` + hash + `"`)

if r.Header.Get("If-None-Match") == expectedETag {
    w.WriteHeader(http.StatusNotModified)
    return
}

This works: after 5 minutes, the client revalidates. If the content hasn't changed, it gets a quick 304. If it has, it gets the new content.

But there's a wrinkle. I want to support deploying discovery documents to a CDN or static file server (probably S3 fronted by a CDN, to be honest). If we rely on each of these URLs sending a 404 or a redirect then we need to either update every URL ever published when we make a change (to redirect to the new location), maintain a very long chain of redirects, or rely on out of band behavior if they start 404'ing (know to go fetch something which will include the new Link header). YUCK.

So, a layer of indirection solves everything, right? What if we separate "what's the current discovery document?" from "what's in that document?"

  • A pointer (/d/current) that says "the current discovery is at /d/abc123"
  • The content (/d/abc123) which is truly immutable

The pointer can have a short cache lifetime. The content can be cached forever. When the content changes:

  1. Deploy new content at /d/xyz789
  2. Update the pointer to redirect to /d/xyz789
  3. Clients' cached pointers expire after 5 minutes
  4. They fetch the pointer, get redirected to the new content
  5. Old content at /d/abc123 can stay around (or be garbage collected later)

The built-in policy server is in Go, so the redirect handler is trivial:

func NewDiscoveryRedirectHandler(hash string) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Cache-Control", "max-age=300")
        w.Header().Set("Location", "/d/"+hash)
        w.WriteHeader(http.StatusFound)
    }
}

The content handler remains unchanged with immutable caching:

w.Header().Set("Cache-Control", "max-age=31536000, immutable")

The Link header now always points to /d/current:

w.Header().Set("Link", "</d/current>; rel=\"discovery\"")

HTTP caches handle this beautifully:

  1. Client requests /d/current
  2. Gets 302 redirect to /d/abc123 with max-age=300
  3. Follows redirect, gets content with immutable
  4. Both responses are cached appropriately
  5. After 5 minutes, the redirect expires
  6. Next request fetches /d/current again
  7. Might get same redirect (cache hit on content) or new one

The content-addressed URLs can be served from anywhere: a CDN, S3, a static file server. They never need invalidation logic. The redirect endpoint is the only "dynamic" part, and it's just returning a Location header.

The final implementation is about 10 lines of code. Most of the work was figuring out the right design :-)

The best part: this doesn't mandate a specific implementation. The contract between client and server is just "respect HTTP caching headers."

A policy server could:

  • Use this same redirect pattern
  • Use ETag with conditional requests
  • Use a short max-age without immutable
  • Some other scheme entirely

As long as the server sets appropriate Cache-Control headers and the client respects them, it works. The client just uses a standard RFC 7234 compliant HTTP cache (in my case, github.com/gregjones/httpcache.

Sometimes the best solution is realizing HTTP already solved your problem decades ago.

Want to try Epithet? Check out the GitHub repo.