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
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:
- Deploy new content at
/d/xyz789 - Update the pointer to redirect to
/d/xyz789 - Clients' cached pointers expire after 5 minutes
- They fetch the pointer, get redirected to the new content
- Old content at
/d/abc123can 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:
- Client requests
/d/current - Gets 302 redirect to
/d/abc123withmax-age=300 - Follows redirect, gets content with
immutable - Both responses are cached appropriately
- After 5 minutes, the redirect expires
- Next request fetches
/d/currentagain - 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-agewithoutimmutable - 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.