web / cdn

I front my web apps with a CDN so the origin serves application logic, not static files. The CDN caches assets at edge locations close to users, cutting latency and origin load.

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 HTTP request for a static asset:

Logs will show:

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

This wastes web processes that should handle application logic, not serve static files. Response times degrade as processes queue up.

With a CDN

The first time a user requests an asset:

200 GET /css/app-a1b2c3d4.css

A CDN cache miss "pulls from the origin", making a GET request to the origin server, storing the result in the CDN cache, and serving the result to the user.

Future GET and HEAD requests to the same URL within the cache duration are served from the CDN cache with no request to the origin.

All HTTP requests using verbs other than GET and HEAD proxy through to the origin.

Cache invalidation

To maximize cache efficiency, set long cache headers (1 year) and change the asset URL when the content changes.

Asset fingerprinting embeds a hash of the file's contents in the URL or filename:

/assets/app-a1b2c3d4.css

When the file changes, the hash changes, creating a new URL. The CDN cache misses and pulls the new version from origin. Old URLs remain cached but are no longer requested.

Cache-Control header:

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

The immutable directive eliminates revalidation requests even on page reload.

Compression at the edge

Modern CDNs negotiate compression with the client and encode responses on the fly: gzip for older clients, Brotli (br) where supported, sometimes zstd. The origin serves uncompressed bytes; the CDN does the work close to the user.

Application-level gzip middleware is usually redundant, and can be actively harmful if it forces the CDN to skip Brotli, which compresses text better than gzip.

Configure compression on the CDN; let the origin focus on generating responses.

Don't cache errors

CDNs cache based on the Cache-Control header, not the HTTP status code. If the origin returns a 404 with Cache-Control: public, max-age=31536000, immutable, the CDN will cache that 404 for a year.

This matters during rolling deploys. A request for a new fingerprinted asset may hit an old server that doesn't have the file yet. If that 404 carries cache headers, the CDN edge caches the error and serves it to every subsequent user in that geography.

The fix is at the origin: only set long cache headers on 200 responses. Set Cache-Control: no-store on errors so they pass through to origin on the next request.

Build-time fingerprinting

When a Node toolchain already bundles TS/JS or compiles Sass, esbuild fingerprints as part of the build. It's fast, has sensible defaults, and works without complex configuration.

import * as esbuild from "esbuild";
import { sassPlugin } from "esbuild-sass-plugin";

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

The hash falls out of the same step that bundles and minifies (public/css/app-2H67SL6V.css, public/js/app-JBTSFCY2.js). The server discovers the hashed filename at startup so templates can link to it. Ruby example:

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

Build before starting the web server. Example Render build command:

npm install && npm run build

See cmd/blog for another build-time example.

In-process fingerprinting

For a Go-only server, compute file hashes at startup and serve assets at fingerprinted URLs. This earns its keep when assets are handwritten CSS or pre-built binaries (WASM, fonts), where adding a Node toolchain would dwarf the work, or when I want a single self-contained Go binary deploy. 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: hash binary assets (images, fonts, WASM) directly. For referential assets, rewrite their asset URLs to the hashed versions in memory first, then hash and serve the processed content. CSS does this via url(); JS referencing a WASM file follows the same shape.

Serve fingerprinted assets with 1-year cache headers in production, and from disk with no-cache in development:

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
}

Pass the fingerprinted path to templates for rendering. See html templates for bundling assets into the binary at compile time.

← All articles