ETags and Conditional Requests in Go Handlers Explained
Add strong ETags and If-None-Match handling to Go handlers, including weak comparison, lists of tags and a 304 that repeats the right headers.

An ETag is a short opaque string that names one version of a response. A browser stores it with the cached copy and, the next time it wants the page, sends it back in If-None-Match. If the version on the server is still the same, the server replies 304 Not Modified with no body, and the browser reuses what it has. The bytes of the page are never sent twice. It is a small mechanism with three places to go wrong: what goes into the tag, how the comparison works, and what the 304 must and must not contain.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article implements it as middleware, shows the exchanges on the wire, and covers the cases where a hash of the body is the wrong choice. It builds on cache headers, and the same logic applies to JSON APIs in conditional GET for APIs. The web cluster's overview is a production HTTP server. Everything ran on Go 1.25.6.
The exchange
First request: the server answers 200 with the body and ETag: "v1abc". The browser keeps both. Second request: the browser sends If-None-Match: "v1abc". The server decides whether its current version has that tag. If yes: 304, empty body, and the browser serves the stored copy. If no: a fresh 200 with a new tag. The comparison rules are in RFC 9110, section 13.1.2, and there are two details you have to get right.
First, If-None-Match may hold several tags ("a", "b") or *. A client that has seen several versions, or a cache behind it, may list them all, and the server should answer 304 if any of them matches the current tag. Second, tags come in two strengths. A strong tag ("abc") means the bytes are identical; a weak one (W/"abc") means the content is semantically the same, though the bytes may differ. For If-None-Match on GET and HEAD, the rule is weak comparison: compare the opaque part and ignore the W/ prefix. Ranges and If-Match use strong comparison, which is why a weak tag is no good for resuming downloads.
Middleware that hashes the response
The simplest correct implementation buffers the handler's response, hashes it, sets ETag, and compares. It handles any handler without touching it.
package main
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
// bufferWriter holds the response until the middleware has decided what to send.
type bufferWriter struct {
header http.Header
status int
body bytes.Buffer
}
func (b *bufferWriter) Header() http.Header { return b.header }
func (b *bufferWriter) WriteHeader(status int) { b.status = status }
func (b *bufferWriter) Write(p []byte) (int, error) { return b.body.Write(p) }
// matches reports whether any tag in an If-None-Match header equals etag, using weak comparison.
func matches(header, etag string) bool {
if strings.TrimSpace(header) == "*" {
return true
}
opaque := func(t string) string { return strings.TrimPrefix(strings.TrimSpace(t), "W/") }
for _, candidate := range strings.Split(header, ",") {
if opaque(candidate) == opaque(etag) {
return true
}
}
return false
}
func ETag(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
next.ServeHTTP(w, r)
return
}
buf := &bufferWriter{header: http.Header{}, status: http.StatusOK}
next.ServeHTTP(buf, r)
for k, v := range buf.header {
w.Header()[k] = v
}
if buf.status == http.StatusOK {
sum := sha256.Sum256(buf.body.Bytes())
etag := `"` + base64.RawURLEncoding.EncodeToString(sum[:12]) + `"`
w.Header().Set("ETag", etag)
if inm := r.Header.Get("If-None-Match"); inm != "" && matches(inm, etag) {
w.WriteHeader(http.StatusNotModified) // no body; ETag and Cache-Control are already set above
return
}
}
w.WriteHeader(buf.status)
w.Write(buf.body.Bytes())
})
}
func main() {
page := "<h1>Pricing</h1>"
h := ETag(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, page)
}))
do := func(label string, inm string) string {
req := httptest.NewRequest("GET", "/pricing", nil)
if inm != "" {
req.Header.Set("If-None-Match", inm)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
fmt.Printf("%-34s %d etag=%s cache-control=%q body=%d bytes\n", label, rec.Code,
rec.Header().Get("ETag"), rec.Header().Get("Cache-Control"), rec.Body.Len())
return rec.Header().Get("ETag")
}
tag := do("first visit", "")
do("revisit with the tag", tag)
do("weak form of the same tag", "W/"+tag)
do("list that contains it", `"stale1", `+tag+`, "stale2"`)
do("wildcard", "*")
do("a tag from an older version", `"old"`)
page = "<h1>Pricing, now cheaper</h1>"
do("same tag after the page changed", tag)
}first visit 200 etag="gxsRBy1NKXs70ogH" cache-control="no-cache" body=16 bytes
revisit with the tag 304 etag="gxsRBy1NKXs70ogH" cache-control="no-cache" body=0 bytes
weak form of the same tag 304 etag="gxsRBy1NKXs70ogH" cache-control="no-cache" body=0 bytes
list that contains it 304 etag="gxsRBy1NKXs70ogH" cache-control="no-cache" body=0 bytes
wildcard 304 etag="gxsRBy1NKXs70ogH" cache-control="no-cache" body=0 bytes
a tag from an older version 200 etag="gxsRBy1NKXs70ogH" cache-control="no-cache" body=16 bytes
same tag after the page changed 200 etag="7aXcCZ9sX2Gi5ila" cache-control="no-cache" body=29 bytesRead the output top to bottom. The first visit returns the page and a tag. Presenting the tag back gives a 304 with no body, and the ETag and Cache-Control headers are present on it. Those are not decoration: RFC 9110 requires a 304 to carry the headers that would have been sent on a 200 and that describe caching (ETag, Cache-Control, Expires, Vary, Content-Location), since the client updates its stored copy from them. A 304 that forgets Cache-Control quietly resets the stored policy. The weak form, a list and the * wildcard all match, and an old tag does not. After the page changes, the same tag no longer matches and the client gets the new body.
What hashing does and does not save
This middleware saves bandwidth and the client's parse time. It does not save the server's work: the handler runs completely, the template renders, the database is queried, and only then is the hash compared. For a page that is expensive to build, a hash-based ETag is a modest win and sometimes a pessimisation, since buffering the body costs memory for large responses.
When the work is the expensive part, derive the tag from something you know before doing the work: a version column, an updated_at timestamp, a content revision number, or the highest ID in a collection. Then the handler checks the tag first and returns 304 without touching the rest:
etag := fmt.Sprintf(`"post-%d-%d"`, post.ID, post.Revision)
w.Header().Set("ETag", etag)
if matches(r.Header.Get("If-None-Match"), etag) {
w.WriteHeader(http.StatusNotModified)
return
}
// ...render and write the full responseThat needs one cheap query (the version) instead of the whole page. It also requires discipline: anything that changes the output must change the tag, including a template edit, a deploy that changes markup and, for per-user pages, the user. Fold the build version into the tag for HTML and it is covered: a deploy invalidates every cached page, which is the behavior you want.
Compression, the classic trap
If a gzip middleware sits after the ETag middleware, or before it, you can end up with one tag for two different byte sequences. Strictly, a strong ETag must change when the encoding changes. The common resolutions are to compute the tag from the uncompressed content and mark it weak (W/"..."), which says "same content, bytes may differ" and is accurate, or to append the encoding to the tag ("abc-gzip"). Apache and nginx have taken both approaches over the years. With ServeContent, the standard library does the right thing for you as long as the compression happens outside it and Vary: Accept-Encoding is set; see serving files with ranges.
Remember also that http.ServeContent honours an ETag you set before calling it, handling If-None-Match, If-Match and If-Range correctly. For file-like responses, set the header and let it do the comparison; the hand-written matches above is for the handlers that do not go through it.
Testing the comparison
The matches function is the part worth a table test of its own, because the bugs live in header parsing. Cover an empty header, a single strong tag, a weak tag against a strong one, a list with spaces after the commas, a list where the match is last, *, and a tag that contains a comma inside the quotes. That last one is legal by the grammar (an opaque tag may contain a comma), and a naive strings.Split breaks on it. If your tags are hashes or version numbers, you control the alphabet and can ignore the case; if tags can come from a database column holding user text, parse the quoted strings properly or restrict the alphabet when you generate the tag.
Also test the sequence, not just the function: GET, then GET with the returned tag, then change the underlying data, then GET with the old tag. The program above does exactly that, and it is the test that catches a middleware ordering mistake, such as ETag running outside a compression layer.
Where I would not bother
An endpoint that is called once per user session, a response that changes on every request (anything with a timestamp or a nonce in the body), and a POST, none of those benefit. And for assets with fingerprinted names, an ETag is redundant: the URL is the validator, and the response is cached for a year. Spend the effort on HTML pages, API collections that clients poll, and feeds, where the second request is usually identical to the first.


