Many of our cacheable HTML GET responses include an ETag. When a browser revalidates a cached page and the representation has not changed, we return 304 Not Modified: no response body, less transfer, and no need to compress that body again.
How It Works
We render the full HTML into a buffer, hash it with xxhash, and compare against the client's If-None-Match header:
func WriteHTMLWithETag(c *gin.Context, body []byte) { c.Header("Content-Type", "text/html; charset=utf-8") etag := ContentETag(body) c.Header("Vary", "Accept, HX-Request, X-Catalog-More") c.Header("ETag", etag) c.Header("Cache-Control", "private, no-cache, must-revalidate") if ETagMatches(c.GetHeader("If-None-Match"), etag) { c.Status(http.StatusNotModified) return } c.Status(http.StatusOK) c.Writer.Write(body) } private prevents shared caches from storing the response. no-cache allows a browser to store it but requires successful revalidation before reuse. must-revalidate adds rules for stale responses when the origin cannot be reached. This policy is conservative; it does not give a CDN permission to cache the page.
If-None-Match can contain * or a comma-separated list of validators, so ETagMatches performs the RFC 9110 comparison rather than simple string equality.
xxhash for Speed
We use cespare/xxhash/v2 because this is a cache validator, not a signature:
func ContentETag(body []byte) string { h := xxhash.New() h.Write(body) return `W/"` + strconv.FormatUint(h.Sum64(), 16) + `"` } The W/ prefix marks a weak entity tag under RFC 9110. Weak validators are suitable for cache revalidation when byte-for-byte identity is not required—for example, when content coding can vary downstream.
Weak does not mean “collisions do not matter.” A collision could incorrectly validate stale content. A fast 64-bit hash makes that risk small for ordinary cache validation, but a cryptographic hash is the safer choice if adversarial collisions are in scope.
Buffer Pooling
Rendering HTML into a buffer before hashing means we allocate a buffer for every request. We use sync.Pool to reuse them:
var htmlBufPool = sync.Pool{ New: func() any { b := make([]byte, 0, 64<<10) // 64 KiB initial return bytes.NewBuffer(b) }, } func AcquireHTMLBuffer() *bytes.Buffer { buf := htmlBufPool.Get().(*bytes.Buffer) buf.Reset() return buf } func ReleaseHTMLBuffer(buf *bytes.Buffer) { if buf.Cap() > 1<<20 { // drop oversized buffers return } htmlBufPool.Put(buf) } The cap prevents an unusually large page from being retained in the pool. The initial capacity should come from profiling typical responses; a pool is not automatically faster, and the runtime may discard pooled items at any time.
The HTMX Complication
The same URL can return a full page for direct navigation and a fragment for an HTMX request. Different bodies naturally produce different ETags, but caches also need to know which request headers select the representation.
We set Vary for those headers:
c.Header("Vary", "Accept, HX-Request, X-Catalog-More") HX-Request differentiates a full page from an HTMX fragment, while X-Catalog-More separates a “load more” fragment from a replacement fragment. The shared helper also varies on Accept for endpoints that negotiate a representation. Keep this list aligned with the headers that can actually change the selected response; unnecessary Vary fields reduce cache reuse.
Where ETags Need Care
- Mutation responses —
304is defined for conditionalGETandHEAD; other methods have different precondition semantics. - Responses that must not be stored — use
Cache-Control: no-store, notno-cache. - Non-200 responses — only return
304when the unconditional response would have been200. A code audit found that our status-aware helper does not yet enforce this guard, so it needs a correctness fix before it is treated as general-purpose. - Pages with a very low revalidation hit rate — rendering and hashing them may add complexity without useful savings.
Private or frequently changing pages are not automatically ineligible. A private ETag can still help when the exact representation is revisited, as long as the validator includes every user-specific input and the response is never shared.
What This Does—and Doesn't—Save
A body-derived ETag is computed after database work and rendering, so a matching request does not save those costs. It saves the response body, transfer, and downstream compression. The browser still makes a network round trip before reusing its cached representation.
If origin CPU or database work is the bottleneck, use a validator derived from a reliable version or last-modified value before rendering. That can short-circuit the expensive work, but only if the version accounts for every input that can change the response.
This article is based on lessons from building Towami, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.

