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:

Environment configuration

Load environment variables early at startup to fail fast on misconfiguration:

type Env struct {
    AppEnv string // "dev" or "prod"
}

func LoadEnv() Env {
    appEnv := os.Getenv("APP_ENV")
    if appEnv == "" {
        appEnv = "dev"
    }
    return Env{AppEnv: appEnv}
}

func (e Env) Dev() bool {
    return e.AppEnv == "dev"
}

func main() {
    env := LoadEnv()
    server := NewServer(env)
    fmt.Printf("Serving at http://localhost:4000 [%s]\n", env.AppEnv)
    log.Fatal(http.ListenAndServe(":4000", server.Handler()))
}

Implementation

I compute file hashes at server startup and serve assets at fingerprinted URLs. In development, skip fingerprinting for live reloading.

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
}

CSS requires special handling because it references other assets via url(). The CSS content is modified in memory to rewrite font and image paths to their fingerprinted versions, then served from memory. Images and fonts are binary blobs that don't reference other files, so they're served 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")
            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")
                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")
                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>

← All articles