Cache Headers for Static and Dynamic Responses in Go
Choose Cache-Control values for fingerprinted assets, HTML pages, API responses and private data in a Go server, and set them from one small middleware.

Cache headers are the cheapest performance work available and the most commonly skipped. A correctly cached static asset costs zero requests on the second page view. An incorrectly cached HTML page serves yesterday's prices to every visitor until their browser decides to look again. Both mistakes come from the same place: Go's standard library sends very few caching headers by default, and the browser fills the silence with guesses.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article lays out the handful of Cache-Control values that cover almost every route in a web application, shows them being set by one small piece of middleware, and prints the headers each class of response ends up with. It belongs to the web cluster that ends in a production HTTP server, and it feeds into ETags and conditional requests and caching responses in middleware. Everything ran on Go 1.25.6.
What Go sends on its own
http.FileServer and http.ServeContent set Last-Modified from the file's modification time and Content-Type from the extension, and honour If-Modified-Since, so a repeated request can be answered with a 304 Not Modified. They do not set Cache-Control or Expires. A browser that sees Last-Modified and nothing else applies a heuristic: it may reuse the response for a fraction of the time since the last modification, typically ten percent. A file changed a month ago can therefore be reused without revalidation for about three days, and a file changed an hour ago for about six minutes. That behavior is allowed by the specification and is why two users see different versions of the same stylesheet after a deploy.
Handlers that write JSON or HTML send no validators and no policy at all. Browsers then do not reuse them without asking, but intermediate caches and CDNs may apply their own defaults. The moment a CDN is added in front of a Go service, everything you did not decide becomes someone else's decision.
The values you actually need
| Response | Header | Why |
|---|---|---|
Fingerprinted asset (app.3f9a2c.js) | public, max-age=31536000, immutable | The URL changes when the content does, so it can be kept for a year, and immutable stops the browser revalidating on reload |
| HTML page | no-cache | Store it, but check with the server before using it; combined with an ETag, the check costs a 304 |
| Public API data that may be a little stale | public, max-age=60 | Shared caches and browsers may reuse it for a minute |
| Per-user data | private, no-cache | Only the user's own browser may store it |
| Secrets, tokens, account pages | no-store | Nothing may keep a copy |
The names are traps. no-cache does not stop caching; it requires revalidation before reuse, which is the right setting for HTML that changes with deploys. no-store is the directive that forbids writing the response anywhere. And private versus public decides whether a shared cache (a CDN, a corporate proxy) may keep the response: a response containing anything user specific must be private, or no-store, because a CDN that caches one user's dashboard will serve it to the next visitor. The rules are in RFC 9111, and MDN's Cache-Control reference lists every directive. The Cache-Control explainer translates a header you paste into plain language.
One middleware, by route class
Scattering w.Header().Set("Cache-Control", ...) through handlers guarantees that someone forgets one. A single middleware that classifies the request is easier to review: the policy lives in a table.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"strings"
)
var fingerprinted = regexp.MustCompile(`\.[0-9a-f]{8,}\.(js|css|woff2|png|jpg|svg|webp)$`)
// CachePolicy sets Cache-Control before the handler runs, unless the handler already chose one.
func CachePolicy(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
policy := "no-cache" // the safe default: store, but always revalidate
switch p := r.URL.Path; {
case fingerprinted.MatchString(p):
policy = "public, max-age=31536000, immutable"
case strings.HasPrefix(p, "/static/"):
policy = "public, max-age=3600" // assets without a hash in the name must expire soon
case strings.HasPrefix(p, "/account"), strings.HasPrefix(p, "/api/me"):
policy = "private, no-store"
case strings.HasPrefix(p, "/api/"):
policy = "private, no-cache"
case p == "/feed.xml":
policy = "public, max-age=300"
}
w.Header().Set("Cache-Control", policy)
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/override" {
w.Header().Set("Cache-Control", "public, max-age=10") // a handler can still decide for itself
}
fmt.Fprint(w, "body")
})
h := CachePolicy(mux)
for _, path := range []string{
"/static/app.3f9a2c41.js",
"/static/logo.png",
"/",
"/library/some-article",
"/feed.xml",
"/api/posts",
"/api/me",
"/account/settings",
"/override",
} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
fmt.Printf("%-26s %s\n", path, rec.Header().Get("Cache-Control"))
}
}/static/app.3f9a2c41.js public, max-age=31536000, immutable
/static/logo.png public, max-age=3600
/ no-cache
/library/some-article no-cache
/feed.xml public, max-age=300
/api/posts private, no-cache
/api/me private, no-store
/account/settings private, no-store
/override public, max-age=10The ordering of the switch matters because the first match wins: the fingerprint pattern comes before the /static/ prefix, so a hashed file gets the year while a plain one gets an hour. The default at the top is deliberately the conservative one. A route nobody classified gets no-cache, which costs a revalidation request and is never wrong; defaulting to long caching would turn every forgotten route into a stale-data bug.
A handler can still override the middleware by setting the header itself, because it runs after the middleware, which is what /override shows. That is the right division: the table sets policy for classes, and a handler with special knowledge, such as one that just computed a response valid for exactly ten seconds, may say so.
Fingerprinting is what makes long caching safe
A year of caching is only safe if changing a file changes its URL. Build tools do this by putting a content hash in the file name, and the HTML references the hashed name. In a Go service, there are two common ways to get it. If a front-end build (Vite, esbuild, webpack) produces the assets, it already emits hashed names and a manifest. If the assets are embedded with //go:embed, compute a hash at startup and build a lookup from logical names to hashed URLs, passed to templates through a function, as in serving embedded static files. The thing to avoid is a long max-age on a path whose content can change, such as /static/app.js. Everyone who loaded it keeps the old version until the year runs out, and you cannot recall it.
Vary, and the shared cache problem
If the response depends on a request header, the cache must know. The classic case is compression: a gzip body and an identity body for the same URL are different representations, and a cache that stores one and serves it to a client that did not ask for it breaks that client. The header that tells caches is Vary: Accept-Encoding. Go's net/http does not add it for you when you compress in middleware, so the compression middleware must; see the gzip middleware. The same applies to Accept-Language for localized pages and Origin for CORS responses. Missing Vary is invisible in testing, since one browser sees one representation, and shows up as a rare, baffling bug once a CDN is in front.
Do not use Vary: Cookie as a cure-all. It makes every distinct cookie its own cache entry, which on a shared cache means the hit rate falls to near zero. Mark such responses private instead.
Testing and checking
Test the table, not the browser. The program above is the test: a list of paths and the exact header expected for each, run through the middleware with httptest. Keep it as a Go test and the next route someone adds under /account will fail the build if it is not private.
For the deployed result, curl -sI https://your.site/path shows what the server and the CDN together send. Look for the Age header from a shared cache, which tells you how long the response has been sitting there, and check each route class once after every infrastructure change. A surprising number of caching bugs are a proxy rule that quietly strips or rewrites Cache-Control.


