web / cdn

I front my web apps with a CDN. The CDN serves static assets from edge locations close to users. The origin serves application logic.

Architecture

A CDN pulls content from its origin server during HTTP requests:

DNS -> CDN -> Origin

Example:

Cloudflare DNS -> Cloudflare CDN -> Render

Without a CDN

If a CNAME record points directly to an app server:

www.example.com -> app.onrender.com

Every static asset request occupies a worker process, which reads the file from disk:

200 GET /css/app.css
200 GET /js/app.js

Those workers queue, and application response times degrade.

With a CDN

The first request for an asset misses the cache. The CDN fetches it from the origin, saves the response at the edge, and returns it:

200 GET /css/app-a1b2c3d4.css

A later GET or HEAD for that URL returns from the edge. Every other method proxies straight to the origin.

Cache invalidation

I set a one-year cache duration and embed a content hash in the file name:

/assets/app-a1b2c3d4.css

A changed file gets a new URL, which misses the cache. Old URLs stay in cache and no client requests them.

Cache-Control header:

Cache-Control: public, max-age=31536000, immutable

The immutable directive eliminates revalidation requests even on page reload.

Compression

The CDN negotiates compression with the client: gzip, Brotli, or zstd. The origin serves uncompressed bytes.

I do not add gzip middleware to the app. A gzip response from the origin makes the CDN skip Brotli, which compresses text better.

Errors

A CDN caches on the Cache-Control header, not the status code. A 404 with a one-year header caches for a year.

During a rolling deploy, a request for a new asset can hit an old server that lacks the file. The edge then serves that 404 for a year.

The origin sets long cache headers on 200 responses only, and Cache-Control: no-store on errors.

Build-time fingerprinting

When a project bundles TypeScript, esbuild fingerprints as part of the build. esbuild has a Go API, so the project needs no JavaScript runtime:

result := api.Build(api.BuildOptions{
	EntryPoints:  []string{"js/app.ts", "css/app.css"},
	Bundle:       true,
	MinifySyntax: true,
	Outdir:       "public",
	EntryNames:   "[dir]/[name]-[hash]",
	Write:        true,
})
if len(result.Errors) > 0 {
	os.Exit(1)
}

The same options in the JavaScript API:

import * as esbuild from "esbuild";

await esbuild.build({
  entryPoints: ["js/app.ts", "css/app.css"],
  bundle: true,
  minify: true,
  outdir: "public",
  entryNames: "[dir]/[name]-[hash]",
});

The output is public/css/app-2H67SL6V.css. The web server finds the hashed name at startup so templates can link to it. Ruby example:

app_css_path = Dir.glob("public/css/app*.css").first&.split("public")&.last

The build runs before the web server starts. A Render build command for the Go version, where bin/build-js runs the program above:

./bin/build-js

See cmd/blog for another build-time example.

In-process fingerprinting

For a Go-only server, I hash files at startup and serve assets at fingerprinted URLs. This fits handwritten CSS, fonts, and WASM that nothing bundles, and a single-binary deploy with no build step. Once there is TypeScript to bundle, I use the esbuild Go API. Dev mode skips fingerprinting for live reload (see env).

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
}

A binary asset hashes directly. For CSS url() or JavaScript that loads WASM, the server rewrites the URLs in memory first, then hashes the result.

Production serves the hashed content with a one-year header. Development serves from disk with no-cache:

func (s *Server) Handler() http.Handler {
    mux := http.NewServeMux()

    if s.env.Dev() {
        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")
        })
    } else {
        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)
        })
    }

    mux.HandleFunc("GET /", s.index)
    return mux
}

Templates receive the fingerprinted paths. See html templates to embed assets in the binary.

← All articles