How to Implement a Cache-Aside Pattern in Go
You are building a profile page. The first time a user loads it, the page hangs for half a second while the database chugs through joins and aggregations. The second time, it renders instantly. You did not change the database. You added a cache. The pattern behind that speedup is cache-aside, and it is the most common way to add caching to a Go service.
Cache-aside means your application code manages the cache directly. When you need data, you check the cache first. If the data is there, you return it immediately. If it is missing, you fetch it from the database, store it in the cache for next time, and then return it. You are the one deciding what goes in and when.
Think of it like keeping snacks on your desk. You check the desk drawer first. If it is empty, you walk to the kitchen, grab a snack, put some back in the drawer, and then eat. The drawer is your cache. The kitchen is the database. The drawer is faster, but the kitchen is the source of truth.
Core logic and minimal example
The implementation follows a strict sequence: check cache, fetch on miss, populate cache, return result. This sequence ensures the cache stays fresh whenever data is accessed.
Here is the simplest runnable version using a thread-safe map.
package main
import (
"fmt"
"sync"
)
// SimpleCache holds key-value pairs with thread-safe access.
type SimpleCache struct {
mu sync.RWMutex
items map[string]string
}
// Get retrieves a value from the cache.
// It returns the value and a boolean indicating presence.
func (c *SimpleCache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.items[key]
return val, ok
}
// Set stores a value in the cache.
func (c *SimpleCache) Set(key, val string) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = val
}
// GetOrFetch checks the cache first, falls back to the DB, and populates the cache.
func GetOrFetch(key string, cache *SimpleCache, dbFunc func(key string) (string, error)) (string, error) {
// Check cache first to avoid expensive database round-trips.
if val, ok := cache.Get(key); ok {
return val, nil
}
// Cache miss: fetch from the source of truth.
val, err := dbFunc(key)
if err != nil {
return "", err
}
// Populate cache so the next request is fast.
cache.Set(key, val)
return val, nil
}
func main() {
cache := &SimpleCache{items: make(map[string]string)}
// Simulate a slow database call.
db := func(k string) (string, error) {
return fmt.Sprintf("data-for-%s", k), nil
}
val, _ := GetOrFetch("user-1", cache, db)
fmt.Println(val) // prints: data-for-user-1
}
Cache-aside puts you in control. You decide what lives in memory.
Walkthrough: what happens at runtime
The function starts by asking the cache. If the key exists, the function returns the value and skips the database entirely. This is the happy path for performance. The sync.RWMutex allows multiple goroutines to read the cache simultaneously via RLock. Only writes acquire the exclusive Lock. This prevents blocking reads while maintaining safety.
If the key is missing, the function calls the database function. If the database returns an error, the error propagates up. The cache stays unchanged. This is intentional. Storing bad data or partial results can cause silent failures later. If the database succeeds, the result goes into the cache. The next caller gets the cached value.
If you try to use a plain map with multiple goroutines, the runtime panics with concurrent map read and map write. Go detects data races at runtime and stops the program to prevent corruption. The mutex in SimpleCache prevents this panic by serializing access.
Realistic example: HTTP handler with database
Real code involves HTTP handlers, structs, and error handling. Here is a handler that serves user data using cache-aside.
The handler checks the cache and returns early on a hit.
package main
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"sync"
)
// User represents a user record from the database.
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// UserCache provides thread-safe caching for user objects.
type UserCache struct {
mu sync.RWMutex
users map[string]User
}
// GetUser handles the request and checks the cache first.
// Context is the first parameter to support cancellation and deadlines.
func GetUser(ctx context.Context, w http.ResponseWriter, r *http.Request, cache *UserCache, db *sql.DB) {
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
// Check cache with read lock to allow concurrent reads.
cache.mu.RLock()
user, found := cache.users[id]
cache.mu.RUnlock()
if found {
// Return cached user immediately.
json.NewEncoder(w).Encode(user)
return
}
// Cache miss: proceed to database fetch.
// ...
}
The database fetch and cache update happen only on a miss.
// Database fetch and cache population.
row := db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = $1", id)
var u User
if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
if err == sql.ErrNoRows {
http.Error(w, "user not found", http.StatusNotFound)
return
}
http.Error(w, "database error", http.StatusInternalServerError)
return
}
// Update cache with write lock.
cache.mu.Lock()
cache.users[id] = u
cache.mu.Unlock()
json.NewEncoder(w).Encode(u)
Context flows down. Errors flow up. Cache updates happen on the way back.
Conventions and details
Go has strong conventions that make this pattern predictable.
context.Context always goes as the first parameter. The handler accepts ctx and passes it to db.QueryRowContext. This allows the database query to respect timeouts and cancellation. If the client disconnects, the context cancels, and the query stops. Functions that take a context should respect cancellation and deadlines.
Error handling is explicit. The code checks if err != nil immediately after Scan. The community accepts this boilerplate because it makes the unhappy path visible. If you ignore the error, the compiler rejects the program with err declared and not used. You must handle or discard every error.
Receiver names are short. The cache methods use (c *SimpleCache) or (cache *UserCache). The receiver name is usually one or two letters matching the type. Do not use (this *Cache) or (self *Cache). Go does not use this or self.
Public names start with a capital letter. GetUser is exported. UserCache is exported. Private names start lowercase. items inside SimpleCache is unexported. This controls visibility.
Trust gofmt. Run it on save. It formats your code consistently. Do not argue about indentation or brace placement. Let the tool decide.
Pitfalls and protection
Cache-aside introduces risks. You must handle them.
Cache stampede occurs when the cache expires and thousands of requests hit the database simultaneously. The database can crash under the load. The solution is request deduplication. Go provides golang.org/x/sync/singleflight for this.
import "golang.org/x/sync/singleflight"
var group singleflight.Group
// GetWithDedup ensures only one goroutine fetches data for a key at a time.
func GetWithDedup(key string, cache *SimpleCache, dbFunc func(key string) (string, error)) (string, error) {
// Check cache outside singleflight to return hits immediately.
if val, ok := cache.Get(key); ok {
return val, nil
}
// singleflight deduplicates concurrent misses.
// Other goroutines wait for the result and get the same value.
val, err, _ := group.Do(key, func() (interface{}, error) {
// Fetch from database.
val, err := dbFunc(key)
if err != nil {
return nil, err
}
// Populate cache.
cache.Set(key, val)
return val, nil
})
if err != nil {
return "", err
}
return val.(string), nil
}
Stampede protection is insurance. Buy it before the traffic spike.
Stale data happens when the database updates but the cache does not know. Cache-aside does not invalidate the cache on writes. You must implement a strategy. Common approaches include setting a time-to-live (TTL) on cache entries or invalidating the cache key when the data is updated. The standard library does not provide a TTL cache. You build one using time.Time in the value or use a library like ristretto.
Memory leaks occur if the cache grows without bounds. Every key stays in memory forever. A cache without eviction is a memory leak waiting to happen. Implement a maximum size and evict old entries. Or use a cache library that handles eviction automatically.
Type mismatches cause compile errors. If you try to assign a struct to a string field, the compiler rejects this with cannot use u (type User) as string value in assignment. Ensure your cache types match your data types.
Decision matrix
Pick the caching pattern that matches your consistency needs.
Use cache-aside when your application controls the read path and you want to keep the database decoupled from caching logic. Use write-through when consistency matters more than latency and you want the cache updated synchronously on every write. Use write-behind when you can tolerate eventual consistency and want to batch writes for maximum throughput. Use no cache when the dataset is small enough to fit in memory or when every read must reflect the absolute latest state.
Pick the pattern that matches your consistency needs, not your fear of complexity.