ADR-016: Caching Strategy
Status
Section titled “Status”Accepted — implementation incomplete, see #135; session-data bullet amended by ADR-025 §3
Amended 2026-09-30 (docs health audit; append-only per ADR-033): status against the code: static assets get
Cache-Control: public, max-age=31536000(noimmutable) with a?v=<build>stamp and error responses go outno-store; there is no ETag/304 handling, noCDN-Cache-Control/Cache-Tag, nosingleflightdependency, and the in-memory cache was removed (amendment in §3). The baseline/target figures in this ADR were targets, never measurements. #135 decides between implementing the remainder and superseding this ADR.
Context
Section titled “Context”Caching is critical for achieving sub-100ms response times defined in ADR-000. Without effective caching, database queries and computations on every request will exceed performance budgets. This ADR establishes caching patterns at multiple levels: HTTP caching, in-memory caching, and precomputation.
The goal is to minimize database queries and expensive computations while maintaining data freshness.
Decision
Section titled “Decision”1. Multi-Level Caching Strategy
Section titled “1. Multi-Level Caching Strategy”Level 1: HTTP Caching (Browser/CDN)
Section titled “Level 1: HTTP Caching (Browser/CDN)”- Static assets: Aggressive caching (1 year)
- Dynamic content: Short TTL with validation
- Private data: No-cache or private cache-control
Level 2: In-Memory Caching (Application)
Section titled “Level 2: In-Memory Caching (Application)”- Frequently accessed data: Cache in Go application memory
- Computed results: Cache expensive calculations
- Session data: Store in memory for fast access
Amended 2026-08-02: the session-data bullet does not apply — sessions are stateless Supabase JWTs carried in cookies with no server-side session state (ADR-025 §3); storing session data in application memory would break the stateless multi-instance design. In-memory caching remains for computed results and hot read paths.
Level 3: Build-Time Precomputation
Section titled “Level 3: Build-Time Precomputation”- Static content: Generate at build time, not runtime
- Navigation structures: Compute once at startup
- Aggregations: Precompute and store
2. HTTP Caching Headers
Section titled “2. HTTP Caching Headers”// Static asset caching (immutable assets with hashed filenames)func serveStaticAssets(w http.ResponseWriter, r *http.Request) { // Aggressive caching for versioned/hashed assets w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("Vary", "Accept-Encoding")
// Serve pre-compressed files if available serveCompressedAsset(w, r)}
// Dynamic page caching (short TTL with revalidation)func serveDynamicPage(w http.ResponseWriter, r *http.Request) { // Cache for 5 minutes, revalidate after w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate") w.Header().Set("ETag", generateETag(content))
// Check If-None-Match for 304 responses if r.Header.Get("If-None-Match") == etag { w.WriteHeader(http.StatusNotModified) return }
renderPage(w, content)}
// Private/authenticated content (no caching)func serveAuthenticatedContent(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0")
renderPrivateContent(w, r)}3. In-Memory Caching
Section titled “3. In-Memory Caching”Amended 2026-08-26 (operator-authorized, #87): the
internal/cachepackage that implemented this section was removed — it shipped with zero call sites and 0% coverage since the demo pivot (ADR-024), and dead code is surface without benefit. The design below remains the accepted pattern: when a measured hot path needs in-memory caching, reintroduce the package from this spec, and per ADR-032’s sequencing rule it then enters test + mutation scope. HTTP/CDN caching and build-time precomputation are implemented and unaffected.
Simple In-Memory Cache
Section titled “Simple In-Memory Cache”package cache
import ( "sync" "time")
type CacheItem struct { Value interface{} Expiration time.Time}
type Cache struct { items map[string]CacheItem mu sync.RWMutex ttl time.Duration}
func NewCache(ttl time.Duration) *Cache { c := &Cache{ items: make(map[string]CacheItem), ttl: ttl, }
// Start cleanup goroutine go c.cleanup()
return c}
func (c *Cache) Set(key string, value interface{}) { c.mu.Lock() defer c.mu.Unlock()
c.items[key] = CacheItem{ Value: value, Expiration: time.Now().Add(c.ttl), }}
func (c *Cache) Get(key string) (interface{}, bool) { c.mu.RLock() defer c.mu.RUnlock()
item, exists := c.items[key] if !exists { return nil, false }
// Check expiration if time.Now().After(item.Expiration) { return nil, false }
return item.Value, true}
func (c *Cache) Delete(key string) { c.mu.Lock() defer c.mu.Unlock() delete(c.items, key)}
func (c *Cache) cleanup() { ticker := time.NewTicker(time.Minute) defer ticker.Stop()
for range ticker.C { c.mu.Lock() now := time.Now() for key, item := range c.items { if now.After(item.Expiration) { delete(c.items, key) } } c.mu.Unlock() }}Usage Example
Section titled “Usage Example”var ( postCache = cache.NewCache(5 * time.Minute))
func GetPost(slug string) (*Post, error) { // Try cache first if cached, ok := postCache.Get(slug); ok { return cached.(*Post), nil }
// Cache miss: query database post, err := db.QueryPost(slug) if err != nil { return nil, err }
// Store in cache postCache.Set(slug, post)
return post, nil}
// Invalidate cache on updatefunc UpdatePost(slug string, post *Post) error { if err := db.UpdatePost(slug, post); err != nil { return err }
// Invalidate cache postCache.Delete(slug)
return nil}4. Build-Time Precomputation
Section titled “4. Build-Time Precomputation”Precompute Navigation at Startup
Section titled “Precompute Navigation at Startup”// Precompute expensive operations at startupvar ( postNavigation map[string]Navigation postIndex []Post)
func init() { log.Info().Msg("precomputing post navigation")
// Fetch all posts once posts, err := fetchAllPosts() if err != nil { log.Fatal().Err(err).Msg("failed to fetch posts") }
// Sort by publish date sort.Slice(posts, func(i, j int) bool { return posts[i].PublishedAt.After(posts[j].PublishedAt) })
// Build navigation map (O(n) instead of O(n²)) postNavigation = make(map[string]Navigation) for i, post := range posts { nav := Navigation{} if i > 0 { nav.Prev = &posts[i-1] } if i < len(posts)-1 { nav.Next = &posts[i+1] } postNavigation[post.Slug] = nav }
postIndex = posts
log.Info().Int("count", len(posts)).Msg("post navigation precomputed")}
// Handler uses precomputed data (O(1) lookup)func HandleBlogPost(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug")
post, err := getPost(slug) if err != nil { http.Redirect(w, r, "/404", http.StatusSeeOther) return }
// O(1) navigation lookup nav := postNavigation[slug]
render(w, post, nav)}Precompute Aggregations
Section titled “Precompute Aggregations”// Cache expensive aggregationsvar ( statsCache *Stats statsCacheMu sync.RWMutex statsLastUpdate time.Time)
func GetStats() (*Stats, error) { statsCacheMu.RLock() // Return cached stats if fresh (< 1 hour old) if time.Since(statsLastUpdate) < time.Hour { defer statsCacheMu.RUnlock() return statsCache, nil } statsCacheMu.RUnlock()
// Recompute stats statsCacheMu.Lock() defer statsCacheMu.Unlock()
// Double-check after acquiring write lock if time.Since(statsLastUpdate) < time.Hour { return statsCache, nil }
stats, err := computeStats() if err != nil { return nil, err }
statsCache = stats statsLastUpdate = time.Now()
return stats, nil}5. Cache Invalidation Strategy
Section titled “5. Cache Invalidation Strategy”Time-Based Expiration (TTL)
Section titled “Time-Based Expiration (TTL)”- Short TTL: 5 minutes for frequently changing data
- Medium TTL: 1 hour for moderately changing data
- Long TTL: 24 hours for rarely changing data
Event-Based Invalidation
Section titled “Event-Based Invalidation”// Invalidate cache on data mutationfunc CreatePost(post *Post) error { if err := db.InsertPost(post); err != nil { return err }
// Invalidate affected caches postCache.Delete(post.Slug) postListCache.Delete("all")
return nil}
// Invalidate related cachesfunc UpdatePost(slug string, post *Post) error { if err := db.UpdatePost(slug, post); err != nil { return err }
// Invalidate post cache postCache.Delete(slug)
// Invalidate list caches that might contain this post postListCache.Delete("all") postListCache.Delete("recent")
return nil}Cache Stampede Prevention
Section titled “Cache Stampede Prevention”import "golang.org/x/sync/singleflight"
var ( requestGroup singleflight.Group)
// Prevent cache stampede using singleflightfunc GetPostWithDedup(slug string) (*Post, error) { // Multiple concurrent requests for same slug will be deduplicated v, err, _ := requestGroup.Do(slug, func() (interface{}, error) { // Check cache if cached, ok := postCache.Get(slug); ok { return cached, nil }
// Cache miss: query database once post, err := db.QueryPost(slug) if err != nil { return nil, err }
// Cache result postCache.Set(slug, post)
return post, nil })
if err != nil { return nil, err }
return v.(*Post), nil}6. CDN Caching (Cloudflare)
Section titled “6. CDN Caching (Cloudflare)”Cloudflare Cache Configuration
Section titled “Cloudflare Cache Configuration”// Set Cloudflare-specific headersfunc cloudflareOptimizedHandler(w http.ResponseWriter, r *http.Request) { // Cache on Cloudflare edge for 1 hour w.Header().Set("Cache-Control", "public, max-age=3600") w.Header().Set("CDN-Cache-Control", "max-age=3600")
// Tell Cloudflare what to vary on w.Header().Set("Vary", "Accept-Encoding")
// Cloudflare-specific: cache even with cookies w.Header().Set("Cache-Tag", "blog-posts")
renderContent(w, r)}
// Purge Cloudflare cache on content updatefunc purgeCloudflareCache(tags []string) error { // Use Cloudflare API to purge cache by tag // Implementation depends on Cloudflare SDK return nil}7. Cache Monitoring
Section titled “7. Cache Monitoring”// Cache metricsvar ( cacheHits = prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "cache_hits_total", Help: "Total number of cache hits", }, []string{"cache"}, )
cacheMisses = prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "cache_misses_total", Help: "Total number of cache misses", }, []string{"cache"}, ))
// Track cache performancefunc GetWithMetrics(key string) (interface{}, bool) { value, ok := cache.Get(key)
if ok { cacheHits.WithLabelValues("post").Inc() } else { cacheMisses.WithLabelValues("post").Inc() }
return value, ok}Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Performance: Achieves sub-100ms response times via reduced database load
- Scalability: Handles more requests with same infrastructure
- Cost Efficiency: Reduced database queries = lower infrastructure costs
- User Experience: Faster page loads improve satisfaction
- CDN Benefits: Global edge caching improves latency for distributed users
Negative
Section titled “Negative”- Complexity: Caching adds complexity to code
- Stale Data: Risk of serving outdated content
- Memory Usage: In-memory cache consumes RAM
- Invalidation Difficulty: Cache invalidation is hard to get right
- Cache Stampede: Many requests hitting cold cache simultaneously
- Memory Exhaustion: Unbounded cache can exhaust memory
- Stale Content: Users may see outdated information
- Debugging: Cached responses harder to debug
Alternatives Considered
Section titled “Alternatives Considered”1. Redis for Distributed Caching
Section titled “1. Redis for Distributed Caching”- Deferred: Start with in-memory cache, add Redis if needed for multi-instance
- Note: Redis adds operational complexity and latency
2. No Caching
Section titled “2. No Caching”- Rejected: Cannot meet performance budgets without caching
3. Database Query Caching Only
Section titled “3. Database Query Caching Only”- Rejected: Still requires database connection, not fast enough
Implementation Checklist
Section titled “Implementation Checklist”Amended 2026-09-30 (docs health audit; append-only per ADR-033): this checklist is the original plan, kept unticked as history; only the static-asset header shipped (see the Status note and #135).
- Implement simple in-memory cache with TTL
- Add HTTP caching headers for static assets
- Add ETag support for dynamic content
- Implement cache invalidation on data mutations
- Add cache stampede prevention (singleflight)
- Precompute navigation structures at startup
- Configure Cloudflare cache rules
- Add cache hit/miss metrics
- Document cache invalidation patterns
- Test cache behavior under load
Performance Impact
Section titled “Performance Impact”Before Caching (Baseline)
Section titled “Before Caching (Baseline)”- Average response time: 150ms (over budget)
- Database queries per request: 5-10
- RPS capacity: 500 requests/second
After Caching (Target)
Section titled “After Caching (Target)”- Average response time: 30ms (well under budget)
- Database queries per request: 0-1 (cache hit)
- RPS capacity: 5000+ requests/second
Cache Hit Rate Targets
Section titled “Cache Hit Rate Targets”- 80%+ cache hit rate for public content
- 90%+ cache hit rate for static assets
- 50%+ cache hit rate for authenticated content
References
Section titled “References”- ADR-000: Performance Budgets
- HTTP Caching - MDN
- Cloudflare Cache
- Go sync/singleflight
- Cache Stampede Prevention
Review Cadence
Section titled “Review Cadence”Review Date: 2026-05-15
Reviewed: 2026-09-30 (docs health audit — open decision tracked in #135)
Date: 2025-11-15 Author: System Architecture Team
Enforcement
Section titled “Enforcement”- Not machine-checkable: Cache-hit-rate targets and invalidation correctness are runtime behaviour; TTL and header values are implementation-coupled and covered by the package’s own tests where written. No standing structural check earns its keep here.
- Graduation log: (empty)