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.