go / fingerprint
I use file-based asset fingerprinting in Go web apps to enable aggressive caching with CDNs.
The problem
When serving CSS, JavaScript, or other static assets, browsers cache them to improve performance. But when I update a file, I need browsers to fetch the new version instead of using the cached copy.
Common solutions like cache headers with short TTLs or ?v=123 query strings
either sacrifice caching performance or require manual version management.
Fingerprinting
Asset fingerprinting generates a unique URL for each version of a file by including a hash of its contents in the filename or path. When the file changes, the hash changes, creating a new URL.
This allows me to:
- Set long cache headers (1 year) for maximum performance
- Automatically invalidate caches when files change
- Avoid manual version tracking
Implementation
Compute file hashes at server startup and serve assets at
fingerprinted URLs. In dev mode, skip fingerprinting for
live reloading (see env for the env.Dev()
toggle).
type Server struct {
env Env
cssPath string // Fingerprinted CSS path
imgPaths map[string]string // Original -> fingerprinted
fontPaths map[string]string // Original -> fingerprinted
cssContent []byte // Processed CSS with rewritten URLs
}
func fileDigest(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := md5.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
func NewServer(env Env) *Server {
s := &Server{
env: env,
imgPaths: make(map[string]string),
fontPaths: make(map[string]string),
}
// In dev mode, skip fingerprinting, load as-is
if s.env.Dev() {
s.cssPath = "/ui/app.css"
return s
}
// Fingerprint images
imgFiles, _ := filepath.Glob("ui/img/*")
for _, file := range imgFiles {
name := filepath.Base(file)
ext := filepath.Ext(name)
base := name[:len(name)-len(ext)]
if hash, err := fileDigest(file); err == nil {
s.imgPaths[name] = fmt.Sprintf("%s-%s%s", base, hash[:8], ext)
}
}
// Fingerprint fonts
fontFiles, _ := filepath.Glob("ui/font/*")
for _, file := range fontFiles {
name := filepath.Base(file)
ext := filepath.Ext(name)
base := name[:len(name)-len(ext)]
if hash, err := fileDigest(file); err == nil {
s.fontPaths[name] = fmt.Sprintf("%s-%s%s", base, hash[:8], ext)
}
}
// Process CSS: rewrite asset URLs to fingerprinted versions
cssBytes, err := os.ReadFile("ui/app.css")
if err != nil {
log.Fatal(err)
}
cssContent := string(cssBytes)
for orig, fp := range s.imgPaths {
cssContent = strings.ReplaceAll(cssContent, "img/"+orig, "img/"+fp)
}
for orig, fp := range s.fontPaths {
cssContent = strings.ReplaceAll(cssContent, "font/"+orig, "font/"+fp)
}
s.cssContent = []byte(cssContent)
// Fingerprint CSS from processed content
h := md5.New()
h.Write(s.cssContent)
s.cssPath = fmt.Sprintf("/ui/app-%s.css", fmt.Sprintf("%x", h.Sum(nil))[:8])
return s
}
The principle: process referential content (CSS, JS) first to rewrite to hashed paths, then hash the processed content. Binary content (images, fonts, WASM) hashes directly.
CSS references other assets via url(), so the CSS content
is modified in memory to rewrite font and image paths to
their fingerprinted versions, then hashed and served from
memory. JavaScript that references binary assets (e.g. a
WASM file) follows the same shape: rewrite the asset paths
in the JS source, then hash the processed JS. Binary blobs
without references serve directly from disk.
Serving assets
In development, serve assets from disk with no-cache headers
so edits are immediately visible.
In production, serve fingerprinted assets with 1-year cache headers.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
noCache := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
next.ServeHTTP(w, r)
})
}
if s.env.Dev() {
// Dev: serve from disk for live reloading
mux.HandleFunc("GET /ui/app.css", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, "./ui/app.css")
})
mux.Handle("GET /ui/img/", noCache(http.StripPrefix("/ui/img/",
http.FileServer(http.Dir("./ui/img")))))
mux.Handle("GET /ui/font/", noCache(http.StripPrefix("/ui/font/",
http.FileServer(http.Dir("./ui/font")))))
} else {
// Production: serve fingerprinted assets with long cache
mux.HandleFunc("GET "+s.cssPath, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Write(s.cssContent)
})
for orig, fp := range s.imgPaths {
origFile := orig
mux.HandleFunc("GET /ui/img/"+fp, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
http.ServeFile(w, r, "./ui/img/"+origFile)
})
}
for orig, fp := range s.fontPaths {
origFile := orig
mux.HandleFunc("GET /ui/font/"+fp, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
http.ServeFile(w, r, "./ui/font/"+origFile)
})
}
}
mux.HandleFunc("GET /", s.index)
return mux
}
Templates
Pass the CSS path to templates for rendering:
type PageData struct {
Title string
CSSPath string
}
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
tmpl := template.Must(template.ParseFiles("ui/index.html"))
tmpl.Execute(w, PageData{
Title: "Home",
CSSPath: s.cssPath,
})
}
In the template:
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.CSSPath}}" />
</head>
<body>
<h1>{{.Title}}</h1>
</body>
</html>
When to use
- Web apps serving static assets (CSS, JS, images, fonts)
- Any site behind a CDN where cache invalidation matters
- Production deployments where performance is important
Go fingerprinting vs esbuild
If you're already running a Node toolchain to bundle TS/JS
or compile Sass, prefer esbuild's build-time
entryNames: "[dir]/[name]-[hash]". The hash falls out of
the same step that's already doing bundling and minification;
the Go server just discovers the hashed filename via glob.
The Go pattern above earns its keep when:
- Assets are handwritten CSS or pre-built binaries (WASM, fonts) where adding a Node toolchain would dwarf the work.
- You want a single self-contained Go binary deploy.
When not to use
- Development environments (skip fingerprinting for live reload)
- Internal tools where caching doesn't matter
See embed for bundling assets into the binary at compile time.