Lab 04 · HTTP Cache Headers
Run it:
make lab-04
Source:labs/lab-04-http-cache-headers/main.go
The Problem
Lab 03 cached everything for a hardcoded TTL. That’s wrong in production: some content must never be cached (authentication tokens), some content is user-specific (shopping carts), and some content changes frequently (live feeds). HTTP defines a rich vocabulary for expressing caching intent — and CDNs are contractually obligated to honor it.
This lab implements the full RFC 7234 caching model: parsing
Cache-Control, handling conditional requests, and generating ETag-based
304 responses.
RFC 7234: The Caching Specification
HTTP caching is defined in RFC 7234 (HTTP/1.1 Caching, 2014), now superseded by RFC 9111 (HTTP Caching, 2022). The spec is 44 pages and covers:
- Freshness: when a cached response can be served without revalidation
- Validation: checking with the origin if the cached copy is still good
- Invalidation: removing cache entries when content changes
The Freshness Calculation
response_is_fresh = (freshness_lifetime > current_age)
freshness_lifetime = max-age directive (if present)
= s-maxage directive (CDN-specific override)
= Expires header - Date header (fallback)
= heuristic: 10% of (Date - Last-Modified) (last resort)
current_age = age_value (from Age header, added by previous CDN)
+ (now - response_time)
The Age header is critical in multi-tier setups: when a shield proxy
serves a cached response to an edge proxy, it adds Age: 120 meaning
“this response is 120 seconds old”. The edge node calculates remaining
freshness as max-age - Age = 300 - 120 = 180 seconds left.
Cache-Control Directives
Request-side (Cache-Control from the client)
| Directive | Meaning |
|---|---|
no-cache | Don’t use cached response; must revalidate |
no-store | Don’t cache this request or its response |
max-age=0 | Treat cached response as stale (same as no-cache in practice) |
max-stale=N | Accept stale response up to N seconds past expiry |
min-fresh=N | Only accept response fresh for at least N more seconds |
only-if-cached | Fail with 504 if no cached copy (offline mode) |
Response-side (Cache-Control from the origin)
| Directive | Meaning |
|---|---|
public | Shared caches (CDN) may store this |
private | Only browser cache; CDN must not store |
no-store | No cache anywhere |
no-cache | Store but always revalidate before use |
max-age=N | Fresh for N seconds |
s-maxage=N | Overrides max-age for CDN/shared caches only |
stale-while-revalidate=N | Serve stale for N seconds while revalidating (lab 07) |
stale-if-error=N | Serve stale for N seconds if origin errors (lab 07) |
immutable | Content won’t change during freshness window (no revalidation) |
must-revalidate | Never serve stale, even if origin is down |
proxy-revalidate | Same as must-revalidate but CDN-specific |
The CDN Override: s-maxage
s-maxage is the CDN operator’s tool. Use it to set a long CDN TTL
while browsers cache for a shorter time:
Cache-Control: public, max-age=60, s-maxage=86400
Browser caches for 60 seconds. CDN caches for 24 hours and refreshes on demand via cache invalidation APIs. This is the standard pattern for versioned static assets where you want browser protection (privacy mode resets) but long CDN caching.
ETags: Content Fingerprinting
An ETag (Entity Tag) is an opaque validator for a specific version of a resource. It can be:
- Strong:
"d41d8cd98f00b204e9800998ecf8427e"— byte-for-byte equality - Weak:
W/"20230101"— semantically equivalent (same meaning, maybe different encoding)
CDN caches store the ETag alongside the response. On the next request (at or after expiry), the cache can send a conditional request:
GET /article/1 HTTP/1.1
If-None-Match: "d41d8cd98f00b204e9800998ecf8427e"
If the content hasn’t changed, the origin returns:
HTTP/1.1 304 Not Modified
ETag: "d41d8cd98f00b204e9800998ecf8427e"
Cache-Control: public, max-age=300
The cache then extends the TTL of the existing response without re-transferring the body. This is a huge bandwidth saving — imagine refreshing a 10 MB PDF that hasn’t changed; you pay ~200 bytes (304 response headers) instead of 10 MB.
ETag Generation Strategies
// MD5 of content (lab 04)
etag := fmt.Sprintf(`"%x"`, md5.Sum(body))
// xxhash (faster, non-cryptographic)
etag := fmt.Sprintf(`"%016x"`, xxhash.Sum64(body))
// Semantic versioning
etag := fmt.Sprintf(`"v%s-%s"`, version, contentHash)
// Timestamp-based (weak)
etag := fmt.Sprintf(`W/"%d"`, lastModified.Unix())
Prefer xxhash over MD5 for performance: xxhash is 10–20× faster and provides sufficient collision resistance for ETags (not a security primitive, just a freshness validator).
Last-Modified / If-Modified-Since
Before ETags existed (HTTP/1.0 era), conditional requests used timestamps:
GET /article/1 HTTP/1.1
If-Modified-Since: Wed, 01 Jan 2025 00:00:00 GMT
Origin returns 304 Not Modified if content hasn’t changed since that
timestamp. This is weaker than ETags because:
- 1-second granularity: If content changes and reverts within 1 second, the cache won’t detect the change.
- Time-zone ambiguity: Distributed systems with clock skew may return stale content.
- Database writes don’t always update mtime: Application-level content may be “modified” logically without a filesystem timestamp change.
Use ETags when possible. Use Last-Modified as a fallback for static
files where the mtime is reliable.
The 304 Path in Practice
CDN cache (entry expired, has ETag stored)
│
│ GET /article/1
│ If-None-Match: "abc123"
▼
Origin
│ → Content unchanged
│ 304 Not Modified
│ ETag: "abc123"
▼
CDN cache
│ → Reset TTL, keep cached body
│ → Serve existing body + new TTL to client
▼
Client receives 200 OK with cached body
The 304 shortcut saves:
- Body transfer bandwidth (origin → CDN)
- Origin CPU/DB work (content generation)
- CDN memory allocation (no new copy of body)
Production Detail: s-maxage=0
A common pattern for HTML pages that reference versioned assets:
Cache-Control: public, max-age=0, s-maxage=0, must-revalidate
“Must revalidate every time, but please cache the ETag so you can use 304 responses.” This ensures pages are always fresh while still using conditional requests to avoid full re-transfers.
Another pattern — Cloudflare’s “Edge Cache TTL” override: even if the
origin sends max-age=0, Cloudflare’s dashboard can set a longer CDN
TTL, overriding the origin’s preference. This is useful for origins you
don’t control. Fastly calls this “Surrogate-Control”:
Surrogate-Control: max-age=86400
Fastly honors Surrogate-Control for CDN TTL and strips it before sending
to browsers. The browser then sees only Cache-Control with shorter TTL.
What to Measure
# 304 rate (healthy revalidation; saves bandwidth)
rate(http_responses_total{status="304"}[5m])
# Ratio of 304 vs full responses (should be 20-50% on well-cached APIs)
rate(http_responses_total{status="304"}[5m]) /
rate(http_responses_total{status="200"}[5m])
# Uncacheable responses (watch for unexpected growth)
rate(cache_store_skipped_total{reason="no-store"}[5m])
rate(cache_store_skipped_total{reason="private"}[5m])
Try It
make lab-04
# Observe Cache-Control header
curl http://localhost:9001/article/1 -v 2>&1 | grep -i cache
# Conditional request (ETag)
ETAG=$(curl http://localhost:9001/article/1 -si | grep -i etag | awk '{print $2}')
curl http://localhost:9001/article/1 -H "If-None-Match: $ETAG" -v
# → Should return 304
# Test no-store (not cached)
curl http://localhost:9001/private/1 -v