Skip to content
AyoKoding

Caching

Why Caching Matters

Caching reduces database load, improves response times, and scales applications by storing frequently accessed data in fast storage (memory, Redis). Without caching, every request hits the database causing slow responses, high latency, and poor user experience under load. Understanding caching patterns prevents performance bottlenecks, reduces infrastructure costs, and enables horizontal scaling.

Core benefits:

  • Performance: 100-1000x faster than database queries
  • Scalability: Reduces database load (serves more users)
  • Cost reduction: Less database resources needed
  • Availability: Serves cached data even if database slow

Problem: Standard library provides sync.Map for concurrent access but no TTL (time-to-live), no eviction policies, no distributed caching. Manual implementation leads to memory leaks and stale data.

Solution: Start with sync.Map for basic in-memory caching to understand fundamentals, identify limitations (no TTL, no eviction), then use production libraries (go-cache for in-memory with TTL, Redis for distributed) for comprehensive caching.

Standard Library: sync.Map

Go's sync.Map provides thread-safe map for concurrent access.

Pattern from standard library:

package main
 
import (
    "fmt"
    // => Standard library for formatted output
    "sync"
    // => Standard library for concurrency primitives
    // => sync.Map is concurrent-safe map
    "time"
    // => Standard library for time operations
)
 
var cache sync.Map
// => Global cache (thread-safe)
// => No initialization needed
// => Optimized for append-once, read-many pattern
 
func getUser(id int) string {
    // => Gets user from cache or database
    // => Returns user data
 
    // Check cache first
    if value, ok := cache.Load(id);ok {
        // => cache.Load(key) returns (value, found)
        // => value is interface{} (type assertion needed)
        // => found is true if key exists
 
        fmt.Println("Cache hit for user", id)
        // => Data found in cache (fast path)
 
        return value.(string)
        // => Type assertion to string
        // => Panics if wrong type (production: check type)
    }
 
    // Cache miss: fetch from database
    fmt.Println("Cache miss for user", id)
    // => Data not in cache (slow path)
 
    user := fetchFromDatabase(id)
    // => Simulates database query
    // => user is "User-1", "User-2", etc.
 
    cache.Store(id, user)
    // => Store in cache for future requests
    // => cache.Store(key, value) is thread-safe
    // => No expiration (cached forever)
 
    return user
}
 
func fetchFromDatabase(id int) string {
    // => Simulates slow database query
    // => Production: actual database call
 
    time.Sleep(100 * time.Millisecond)
    // => Simulates 100ms database latency
    // => Real databases: 10-100ms typical
 
    return fmt.Sprintf("User-%d", id)
    // => Returns user data
}
 
func main() {
    // First request: cache miss
    fmt.Println(getUser(1))
    // => Output: Cache miss for user 1
    // => Output: User-1
    // => 100ms delay (database query)
 
    // Second request: cache hit
    fmt.Println(getUser(1))
    // => Output: Cache hit for user 1
    // => Output: User-1
    // => < 1ms (from memory)
 
    // Concurrent access (safe with sync.Map)
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            getUser(id)
            // => Multiple goroutines access cache safely
            // => No data races
        }(i % 3)  // Access users 0, 1, 2 repeatedly
    }
    wg.Wait()
}

Deleting from cache:

package main
 
import (
    "sync"
)
 
var cache sync.Map
 
func invalidateUser(id int) {
    // => Removes user from cache
    // => Called when user updated in database
 
    cache.Delete(id)
    // => Delete(key) removes entry
    // => Thread-safe operation
    // => Next getUser() will fetch from database
}
 
func updateUser(id int, newData string) {
    // => Updates user in database and cache
 
    updateDatabase(id, newData)
    // => Update database first
 
    invalidateUser(id)
    // => Remove from cache (or update cache directly)
    // => Next request will fetch updated data
}
 
func updateDatabase(id int, newData string) {
    // => Simulates database update
    // Production: actual database UPDATE query
}

Limitations for production caching:

  • No TTL (time-to-live) - entries cached forever
  • No eviction policies (memory leaks possible)
  • No cache size limits (unbounded growth)
  • No statistics (cache hit rate, miss rate)
  • No distributed caching (single-process only)
  • No automatic expiration (manual deletion required)

Production Framework: In-Memory Cache with TTL

go-cache provides in-memory caching with TTL and eviction.

Adding go-cache:

go get github.com/patrickmn/go-cache
# => Installs in-memory cache library
# => Supports TTL, eviction, cleanup

Pattern: Cache with TTL:

package main
 
import (
    "fmt"
    "time"
 
    "github.com/patrickmn/go-cache"
    // => In-memory cache library
    // => Thread-safe, supports TTL
)
 
var c *cache.Cache
// => Global cache instance
 
func init() {
    // => Initializes cache on package load
 
    c = cache.New(5*time.Minute, 10*time.Minute)
    // => New(defaultTTL, cleanupInterval)
    // => defaultTTL: 5 minutes (entries expire after 5min)
    // => cleanupInterval: 10 minutes (cleanup expired entries every 10min)
    // => Thread-safe (multiple goroutines safe)
}
 
func getUser(id int) (string, error) {
    // => Gets user from cache or database
    // => Returns user data or error
 
    key := fmt.Sprintf("user:%d", id)
    // => key is "user:1", "user:2", etc.
    // => Namespace keys to avoid collisions
 
    // Check cache
    if value, found := c.Get(key); found {
        // => c.Get(key) returns (value, found)
        // => value is interface{} (type assertion needed)
 
        fmt.Println("Cache hit for", key)
        return value.(string), nil
        // => Type assertion to string
        // => Cache hit (fast path)
    }
 
    // Cache miss
    fmt.Println("Cache miss for", key)
    user := fetchFromDatabase(id)
 
    c.Set(key, user, cache.DefaultExpiration)
    // => Set(key, value, duration)
    // => cache.DefaultExpiration uses 5 minutes (from New())
    // => Alternative: specific duration (1*time.Hour)
    // => cache.NoExpiration for no TTL
 
    return user, nil
}
 
func getUserWithCustomTTL(id int, ttl time.Duration) (string, error) {
    // => Gets user with custom TTL
    // => Different data has different freshness requirements
 
    key := fmt.Sprintf("user:%d", id)
 
    if value, found := c.Get(key); found {
        return value.(string), nil
    }
 
    user := fetchFromDatabase(id)
 
    c.Set(key, user, ttl)
    // => Custom TTL (e.g., 1 hour for frequently accessed data)
 
    return user, nil
}
 
func invalidateUser(id int) {
    // => Removes user from cache
    // => Called when user updated
 
    key := fmt.Sprintf("user:%d", id)
    c.Delete(key)
    // => Delete(key) removes entry immediately
    // => Next request will miss cache
}
 
func getCacheStats() {
    // => Gets cache statistics
 
    itemCount := c.ItemCount()
    // => Number of items in cache
 
    fmt.Printf("Cache items: %d\n", itemCount)
    // => Output: Cache items: 42
}
 
func fetchFromDatabase(id int) string {
    time.Sleep(100 * time.Millisecond)
    return fmt.Sprintf("User-%d", id)
}
 
func main() {
    // First request: cache miss
    user, _ := getUser(1)
    fmt.Println(user)
    // => Output: Cache miss for user:1
    // => Output: User-1
 
    // Second request: cache hit
    user, _ = getUser(1)
    fmt.Println(user)
    // => Output: Cache hit for user:1
    // => Output: User-1
 
    // Wait for TTL expiration
    time.Sleep(6 * time.Minute)
    // => Entry expired after 5 minutes
 
    // Request after expiration: cache miss
    user, _ = getUser(1)
    // => Output: Cache miss for user:1
    // => Fetches from database again
 
    getCacheStats()
}

Pattern: Cache-Aside (Lazy Loading):

package main
 
import (
    "fmt"
    "time"
 
    "github.com/patrickmn/go-cache"
)
 
var c *cache.Cache
 
func init() {
    c = cache.New(5*time.Minute, 10*time.Minute)
}
 
func getUser(id int) (string, error) {
    // => Cache-Aside pattern (most common)
    // => Application manages cache loading
 
    key := fmt.Sprintf("user:%d", id)
 
    // 1. Check cache first
    if value, found := c.Get(key); found {
        return value.(string), nil
        // => CACHE HIT: return immediately
    }
 
    // 2. Cache miss: fetch from database
    user := fetchFromDatabase(id)
 
    // 3. Update cache for next request
    c.Set(key, user, cache.DefaultExpiration)
 
    // 4. Return data
    return user, nil
    // => CACHE MISS: fetch, cache, return
}

Pattern: Write-Through Cache:

package main
 
import (
    "fmt"
    "time"
 
    "github.com/patrickmn/go-cache"
)
 
var c *cache.Cache
 
func updateUser(id int, newData string) error {
    // => Write-Through pattern
    // => Updates database AND cache together
 
    key := fmt.Sprintf("user:%d", id)
 
    // 1. Update database first
    if err := updateDatabase(id, newData); err != nil {
        // => Database update failed
 
        return err
        // => Don't update cache if database fails
    }
 
    // 2. Update cache (keep in sync)
    c.Set(key, newData, cache.DefaultExpiration)
    // => Cache now consistent with database
 
    return nil
}
 
func updateDatabase(id int, newData string) error {
    // => Simulates database UPDATE
    // Production: actual SQL UPDATE
    time.Sleep(50 * time.Millisecond)
    return nil
}

Production Framework: Distributed Cache with Redis

Redis provides distributed caching across multiple servers.

Adding go-redis:

go get github.com/redis/go-redis/v9
# => Installs Redis client library
# => v9 supports Redis 7.0+

Pattern: Redis Cache:

package main
 
import (
    "context"
    "fmt"
    "time"
 
    "github.com/redis/go-redis/v9"
    // => Redis client library
    // => Thread-safe, connection pooling
)
 
var rdb *redis.Client
// => Global Redis client
 
func init() {
    // => Initializes Redis connection
 
    rdb = redis.NewClient(&redis.Options{
        Addr:     "localhost:6379",
        // => Redis server address
        // => Production: load from environment variable
 
        Password: "",
        // => Redis password (empty if no auth)
        // => Production: load from secure config
 
        DB:       0,
        // => Database number (0-15)
        // => Different databases for different apps
 
        PoolSize: 10,
        // => Connection pool size
        // => More connections = more concurrent requests
    })
    // => Creates Redis client with connection pool
    // => Thread-safe (reuse across goroutines)
}
 
func getUser(ctx context.Context, id int) (string, error) {
    // => Gets user from Redis or database
    // => ctx for timeout and cancellation
 
    key := fmt.Sprintf("user:%d", id)
 
    // Check Redis cache
    value, err := rdb.Get(ctx, key).Result()
    // => rdb.Get(ctx, key) returns StringCmd
    // => .Result() returns (string, error)
    // => err is redis.Nil if key doesn't exist
 
    if err == redis.Nil {
        // => Cache miss (key not found)
 
        fmt.Println("Cache miss for", key)
 
        user := fetchFromDatabase(id)
 
        // Store in Redis with TTL
        err := rdb.Set(ctx, key, user, 5*time.Minute).Err()
        // => Set(ctx, key, value, expiration)
        // => Expires after 5 minutes
        // => .Err() returns error or nil
 
        if err != nil {
            // => Redis SET failed (log but continue)
            fmt.Println("Redis set error:", err)
        }
 
        return user, nil
    } else if err != nil {
        // => Redis connection error
 
        fmt.Println("Redis error:", err)
        // => Log error, fallback to database
 
        return fetchFromDatabase(id), nil
        // => Graceful degradation (continue without cache)
    }
 
    // Cache hit
    fmt.Println("Cache hit for", key)
    return value, nil
}
 
func invalidateUser(ctx context.Context, id int) error {
    // => Removes user from Redis
    // => Called when user updated
 
    key := fmt.Sprintf("user:%d", id)
 
    err := rdb.Del(ctx, key).Err()
    // => Del(ctx, keys...) deletes keys
    // => Returns error if deletion fails
 
    return err
}
 
func main() {
    ctx := context.Background()
    // => Background context for operations
 
    // First request: cache miss
    user, _ := getUser(ctx, 1)
    fmt.Println(user)
    // => Output: Cache miss for user:1
    // => Output: User-1
 
    // Second request: cache hit
    user, _ = getUser(ctx, 1)
    fmt.Println(user)
    // => Output: Cache hit for user:1
    // => Output: User-1
 
    // Invalidate cache
    invalidateUser(ctx, 1)
    fmt.Println("Cache invalidated")
 
    // Request after invalidation: cache miss
    user, _ = getUser(ctx, 1)
    // => Output: Cache miss for user:1
}
 
func fetchFromDatabase(id int) string {
    time.Sleep(100 * time.Millisecond)
    return fmt.Sprintf("User-%d", id)
}

Pattern: Cache Warming:

package main
 
import (
    "context"
    "fmt"
 
    "github.com/redis/go-redis/v9"
)
 
func warmCache(ctx context.Context) error {
    // => Pre-loads frequently accessed data into cache
    // => Called on application startup
 
    popularUserIDs := []int{1, 2, 3, 10, 42}
    // => Most accessed users (from analytics)
    // => Production: query database for popular IDs
 
    for _, id := range popularUserIDs {
        user := fetchFromDatabase(id)
        // => Fetch from database
 
        key := fmt.Sprintf("user:%d", id)
        rdb.Set(ctx, key, user, 5*time.Minute)
        // => Pre-populate cache
        // => First user requests will be cache hits
    }
 
    fmt.Println("Cache warmed")
    return nil
}

Trade-offs: When to Use Each

Comparison table:

ApproachScopePersistenceUse Case
sync.MapSingle processIn-memoryDevelopment, testing
go-cacheSingle processIn-memorySingle-server apps, session storage
RedisDistributedPersistentMulti-server apps, microservices
MemcachedDistributedIn-memoryHigh-throughput caching

When to use sync.Map:

  • Development and testing (simple, no dependencies)
  • Single process (no distributed caching needed)
  • Short-lived data (no TTL required)
  • Append-once, read-many pattern

When to use go-cache:

  • Single-server applications (no distribution needed)
  • Session storage (in-memory sessions)
  • Temporary data with TTL (automatic expiration)
  • Low latency requirements (<1ms)

When to use Redis:

  • Multi-server applications (shared cache)
  • Microservices (distributed cache)
  • Persistent caching (survives restarts)
  • Advanced features (sorted sets, pub/sub, transactions)
  • High availability (Redis Cluster, Redis Sentinel)

When to use Memcached:

  • High-throughput caching (10K+ req/sec)
  • Simple key-value storage (no complex data structures)
  • Lower memory footprint than Redis

Production Best Practices

Set appropriate TTLs based on data freshness:

// GOOD: different TTLs for different data
c.Set("user:profile:1", profile, 1*time.Hour)      // Rarely changes
c.Set("user:session:1", session, 15*time.Minute)   // Short-lived
c.Set("product:inventory:1", inventory, 1*time.Minute)  // Frequently updated
 
// BAD: same TTL for all data
c.Set("key", value, cache.DefaultExpiration)  // Ignores data freshness requirements

Handle cache stampede (thundering herd):

// GOOD: use singleflight to prevent cache stampede
import "golang.org/x/sync/singleflight"
 
var g singleflight.Group
 
func getUser(id int) (string, error) {
    key := fmt.Sprintf("user:%d", id)
 
    // Check cache
    if value, found := c.Get(key); found {
        return value.(string), nil
    }
 
    // singleflight ensures only one database query for concurrent requests
    value, err, _ := g.Do(key, func() (interface{}, error) {
        // => Only one goroutine executes this function
        // => Other goroutines wait for result
        user := fetchFromDatabase(id)
        c.Set(key, user, cache.DefaultExpiration)
        return user, nil
    })
 
    return value.(string), err
}
 
// BAD: cache stampede on expiration
// => 100 concurrent requests all miss cache
// => 100 database queries executed simultaneously

Implement cache fallback on errors:

// GOOD: graceful degradation
func getUser(ctx context.Context, id int) (string, error) {
    key := fmt.Sprintf("user:%d", id)
 
    // Try Redis
    value, err := rdb.Get(ctx, key).Result()
    if err == redis.Nil {
        // Cache miss (expected)
        return fetchFromDatabase(id), nil
    } else if err != nil {
        // Redis error (log and fallback)
        fmt.Println("Redis error:", err)
        return fetchFromDatabase(id), nil  // Graceful degradation
    }
 
    return value, nil
}
 
// BAD: fail on cache errors
if err != nil {
    return "", err  // Application fails if Redis down
}

Monitor cache hit rate:

// Track cache performance
type CacheStats struct {
    Hits   int64
    Misses int64
}
 
func (s *CacheStats) HitRate() float64 {
    total := s.Hits + s.Misses
    if total == 0 {
        return 0
    }
    return float64(s.Hits) / float64(total)
}
 
// Log cache hit rate periodically
// => Target: 80%+ hit rate for production caches

Summary

Caching improves performance and scalability by storing frequently accessed data in fast storage. Standard library provides sync.Map for concurrent access but no TTL, eviction, or distribution. Production systems use go-cache for in-memory caching with TTL and automatic cleanup, Redis for distributed caching across multiple servers. Use cache-aside for lazy loading, write-through for consistency, and singleflight to prevent cache stampede. Monitor cache hit rate (target 80%+) and implement graceful degradation on cache errors.

Key takeaways:

  • sync.Map provides thread-safe map but no TTL or eviction
  • go-cache adds TTL and automatic cleanup for single-process caching
  • Redis enables distributed caching across multiple servers
  • Cache-aside (lazy loading) is most common pattern
  • Use singleflight to prevent cache stampede (thundering herd)
  • Set TTLs based on data freshness requirements
  • Implement graceful degradation on cache errors
  • Monitor cache hit rate (target 80%+ for production)

Last updated February 3, 2026

Command Palette

Search for a command to run...