Cache Headers and Fingerprinting

Use Cache-Control, ETags and content-hashed filenames so browsers stop downloading assets that have not changed.

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

A browser that already holds your stylesheet should not fetch it again. Response headers are how the server says so. Two of them do most of the work.

Cache-Control carries the rules. max-age=600 means the copy may be reused for 600 seconds without asking. no-store means keep no copy at all. public allows shared caches, such as a CDN, to keep it; private limits it to the one browser. immutable promises the bytes at this URL will never change, so the browser should not even check.

ETag and the 304

An ETag is a short label for the current contents, usually a hash of them, wrapped in double quotes. Send it with the response. On the next request the browser sends If-None-Match carrying that label. If it still matches, answer 304 Not Modified with no body, and the browser reuses what it has. http.ServeContent performs that comparison for you when the ETag header is already set, and http.ServeFile and http.FileServer call ServeContent underneath. The long form is the same exchange written out: read r.Header.Get("If-None-Match"), compare, and call w.WriteHeader(http.StatusNotModified) yourself.

Fingerprinting

Put the hash in the file name: app.9f8c2a.css. The URL now changes whenever the contents do, so that URL can be cached for a year with public, max-age=31536000, immutable and there is nothing to invalidate later. The HTML referring to it must not be cached that way, or browsers keep asking for the old name. Serve HTML with no-cache, which means revalidate before reuse, and use private, no-store for anything behind a login.

Example

Example · go
package main

import (
	"bytes"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"
)

var css = []byte("body{margin:0}")
var sum = sha256.Sum256(css)

// The double quotes are part of an ETag. net/http ignores one without them.
var etag = `"` + hex.EncodeToString(sum[:])[:16] + `"`

func asset(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("ETag", etag)
	w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
	// A zero modtime means no Last-Modified header is sent. ServeContent
	// still compares If-None-Match against the ETag set above.
	http.ServeContent(w, r, "app.css", time.Time{}, bytes.NewReader(css))
}

func main() {
	first := httptest.NewRecorder()
	asset(first, httptest.NewRequest("GET", "/app.css", nil))
	fmt.Printf("first:  %d %s %d bytes\n", first.Code, first.Header().Get("ETag"), first.Body.Len())

	// The browser asks again, sending back the label it stored.
	repeat := httptest.NewRequest("GET", "/app.css", nil)
	repeat.Header().Set("If-None-Match", etag)
	second := httptest.NewRecorder()
	asset(second, repeat)
	fmt.Printf("second: %d %d bytes\n", second.Code, second.Body.Len())
}

// Output:
// first:  200 "2007703776e20c24" 14 bytes
// second: 304 0 bytes

When to use it

  • Make a repeat page load fetch no CSS or JavaScript at all.
  • Get a new stylesheet to browsers that cached the previous one for a year.
  • Stop a CDN from storing a signed-in user's page and serving it to someone else.
  • Cut bandwidth on a large JSON response that rarely changes by answering 304.

More examples

The 304 written out by hand

This is the longhand of what ServeContent does for files: hash, set the header, compare, answer 304 or 200. Worth having for an endpoint whose JSON rarely changes but is polled often, such as a feature-flag or configuration response. Real caches may send several labels at once in If-None-Match, so a strict equality check is a simplification you should only make for your own clients.

Example · go
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"net/http"
)

// Use this when the body is generated rather than read from a file, so
// there is no io.ReadSeeker to hand to http.ServeContent.
func writeJSON(w http.ResponseWriter, r *http.Request, body []byte) {
	sum := sha256.Sum256(body)
	tag := `"` + hex.EncodeToString(sum[:])[:16] + `"`

	w.Header().Set("ETag", tag)
	// Reuse only after checking with the server, and never in a shared cache.
	w.Header().Set("Cache-Control", "private, no-cache")

	if r.Header.Get("If-None-Match") == tag {
		// A 304 carries no body. Write one and the client sees a broken reply.
		w.WriteHeader(http.StatusNotModified)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.Write(body)
}

Hashed filenames through a template function

Templates never hard-code the hashed name; they call asset and get whatever this build produced. That is what makes a one-year cache safe: a new build writes a new filename, the HTML points at it, and no cache anywhere has to be cleared.

Example · go
package main

import (
	"html/template"
	"os"
)

// manifest maps a logical name to the fingerprinted file on disk. A build
// step normally writes this as JSON; it is literal here to show the shape.
var manifest = map[string]string{"app.css": "app.9f8c2a.css"}

var funcs = template.FuncMap{
	"asset": func(name string) string {
		return "/static/" + manifest[name]
	},
}

var page = template.Must(template.New("page").Funcs(funcs).Parse(
	`<link rel="stylesheet" href="{{asset "app.css"}}">` + "\n"))

func main() {
	if err := page.Execute(os.Stdout, nil); err != nil {
		panic(err)
	}
}

// Output:
// <link rel="stylesheet" href="/static/app.9f8c2a.css">

Long cache for assets, none for pages

Two mounts, two policies. Getting this backwards is the common production bug: an HTML page cached for a year keeps pointing at a deleted asset, and users see an unstyled site until they clear their cache. Use no-store rather than no-cache for pages that show one user's data, so a shared cache never keeps a copy.

Example · go
package main

import "net/http"

// longCache is middleware: a handler that wraps another handler and adds
// headers on the way out. Safe only because every file under /static/
// has a content hash in its name.
func longCache(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
		h.ServeHTTP(w, r)
	})
}

func routes(page http.Handler) *http.ServeMux {
	mux := http.NewServeMux()
	files := http.StripPrefix("/static/", http.FileServer(http.Dir("public")))
	mux.Handle("/static/", longCache(files))
	// Pages name the hashed files, so they must be revalidated every time.
	mux.Handle("/", noStore(page))
	return mux
}

func noStore(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Cache-Control", "no-store")
		h.ServeHTTP(w, r)
	})
}

Discussion

  • Be the first to comment on this lesson.