← Back to list

Boosting Golang APIs with Caching: Strategies, Examples, and Pitfalls to Avoid

Learn how to implement caching in your Golang API. Explore strategies, invalidation, code examples, and real-world use cases.

Gopher in Stackademic · 2025-09-27 06:22 · 56 claps · 7.6 min read
#golang #api #caching #performance #backend
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Boosting Golang APIs with Caching: Strategies, Examples, and Pitfalls to Avoid

If you’ve ever built an API that suddenly got popular (congrats!), you’ve probably experienced that moment of panic when your database starts crying for help and response times creep up. That’s where caching comes in — it’s like having a cheat sheet for your API to quickly look up answers without doing all the hard work again.

In this post, I’ll walk you through implementing caching in your Go API — from understanding the basics to avoiding those “how did I break everything?” moments we’ve all had.

When and Why Do We Need a Cache?

Caching isn’t just about speed (though your users will thank you for that). It’s also about:

  • Reducing load: Databases and third-party APIs often become the bottleneck. Caching lets them breathe.
  • Improving latency: Returning a cached response can be milliseconds faster than recomputing or re-querying.
  • Saving costs: If you’re paying per query (looking at you, SaaS APIs 💸), cache can reduce bills.
  • Smoothing spikes: Caches can help your API survive sudden traffic surges.

But caching isn’t a silver bullet. If your data changes frequently (like stock prices or real-time chat messages), a cache might hurt more than it helps.

Different Types of Cache Strategies

Not all caching approaches are created equal. Here are the main strategies you’ll want to consider:

1. In-Memory Cache

This is the simplest approach — store data right in your application’s memory. Great for single-instance applications or when you’re just getting started.

Pros: Super fast, easy to implement Cons: Doesn’t work well with multiple API instances, cache is lost on restart

2. Distributed Cache

When you have multiple API instances, you need a central cache that all instances can access. Redis and Memcached are popular choices here.

Pros: Works across multiple instances, survives application restarts Cons: Network latency, more complex setup

3. Cache-Aside (Lazy Loading)

The application first checks the cache. If the data exists (cache hit), it returns it. If not (cache miss), it fetches from the database, stores in cache, then returns.

Pros: Only caches what’s actually needed Cons: Initial requests are slow (cold cache)

4. Write-Through

Data is written to both the cache and the database simultaneously.

Pros: Cache is always up-to-date Cons: Write operations take longer

5. Time-Based Expiration

Set an expiration time for cached items to ensure data doesn’t get too stale.

Pros: Simple way to handle data freshness Cons: Data might be stale until expiration

Let’s Code: Implementing In-Memory Cache

Let’s start with a simple in-memory cache implementation using Go’s built-in sync.Map:

package cache

import (
 "sync"
 "time"
)

// Item represents a cached item with value and expiration
type Item struct {
 Value      interface{}
 Expiration int64
}

// MemoryCache implements a simple in-memory cache
type MemoryCache struct {
 items sync.Map
}

// NewMemoryCache creates a new in-memory cache
func NewMemoryCache() *MemoryCache {
 cache := &MemoryCache{}

 // Start a background goroutine to clean expired items
 go cache.cleanExpired()

 return cache
}

// Set adds an item to the cache with an expiration time
func (c *MemoryCache) Set(key string, value interface{}, duration time.Duration) {
 expiration := time.Now().Add(duration).UnixNano()
 c.items.Store(key, Item{
  Value:      value,
  Expiration: expiration,
 })
}

// Get retrieves an item from the cache
func (c *MemoryCache) Get(key string) (interface{}, bool) {
 item, found := c.items.Load(key)
 if !found {
  return nil, false
 }

 // Check if the item has expired
 cachedItem := item.(Item)
 if time.Now().UnixNano() > cachedItem.Expiration {
  c.items.Delete(key)
  return nil, false
 }

 return cachedItem.Value, true
}

// Delete removes an item from the cache
func (c *MemoryCache) Delete(key string) {
 c.items.Delete(key)
}

// cleanExpired periodically removes expired items
func (c *MemoryCache) cleanExpired() {
 ticker := time.NewTicker(5 * time.Minute)
 defer ticker.Stop()

 for {
  select {
  case <-ticker.C:
   c.items.Range(func(key, value interface{}) bool {
    item := value.(Item)
    if time.Now().UnixNano() > item.Expiration {
     c.items.Delete(key)
    }
    return true
   })
  }
 }
}

Using the Cache in an API Handler

Let’s see how to use our cache in a real API endpoint:

package handlers

import (
 "encoding/json"
 "net/http"
 "time"

 "myapp/cache"
 "myapp/models"
)

type ProductHandler struct {
 cache       cache.Cache
 productRepo models.ProductRepository
}

func NewProductHandler(cache cache.Cache, repo models.ProductRepository) *ProductHandler {
 return &ProductHandler{
  cache:       cache,
  productRepo: repo,
 }
}

func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
 productID := r.URL.Query().Get("id")
 if productID == "" {
  http.Error(w, "Product ID is required", http.StatusBadRequest)
  return
 }

 // Create a cache key
 cacheKey := "product:" + productID

 // Try to get from cache first
 if cachedProduct, found := h.cache.Get(cacheKey); found {
  // Cache hit! Return the cached product
  w.Header().Set("Content-Type", "application/json")
  w.Header().Set("X-Cache", "HIT")
  json.NewEncoder(w).Encode(cachedProduct)
  return
 }

 // Cache miss, get from database
 product, err := h.productRepo.FindByID(productID)
 if err != nil {
  http.Error(w, "Product not found", http.StatusNotFound)
  return
 }

 // Store in cache for future requests (cache for 15 minutes)
 h.cache.Set(cacheKey, product, 15*time.Minute)

 // Return the product
 w.Header().Set("Content-Type", "application/json")
 w.Header().Set("X-Cache", "MISS")
 json.NewEncoder(w).Encode(product)
}

Implementing a Redis Cache

For production applications with multiple instances, let’s implement a Redis-based cache:

package cache

import (
 "context"
 "encoding/json"
 "time"

 "github.com/go-redis/redis/v8"
)

// RedisCache implements the Cache interface using Redis
type RedisCache struct {
 client *redis.Client
 ctx    context.Context
}

// NewRedisCache creates a new Redis cache
func NewRedisCache(addr string, password string, db int) *RedisCache {
 client := redis.NewClient(&redis.Options{
  Addr:     addr,
  Password: password,
  DB:       db,
 })

 return &RedisCache{
  client: client,
  ctx:    context.Background(),
 }
}

// Set adds an item to the Redis cache with expiration
func (c *RedisCache) Set(key string, value interface{}, duration time.Duration) {
 // Serialize the value to JSON
 jsonValue, err := json.Marshal(value)
 if err != nil {
  return
 }

 // Store in Redis with expiration
 c.client.Set(c.ctx, key, jsonValue, duration)
}

// Get retrieves an item from the Redis cache
func (c *RedisCache) Get(key string) (interface{}, bool) {
 val, err := c.client.Get(c.ctx, key).Result()
 if err != nil {
  return nil, false
 }

 // For this example, we'll return the raw JSON string
 // In a real app, you'd unmarshal to the correct type
 var result interface{}
 if err := json.Unmarshal([]byte(val), &result); err != nil {
  return nil, false
 }

 return result, true
}

// Delete removes an item from the Redis cache
func (c *RedisCache) Delete(key string) {
 c.client.Del(c.ctx, key)
}

Setting Up Your API with Cache

Now let’s wire everything together in the main application:

package main

import (
 "log"
 "net/http"
 "time"

 "myapp/cache"
 "myapp/handlers"
 "myapp/models"
)

func main() {
 // Choose which cache implementation to use
 // For development or single instance:
 memCache := cache.NewMemoryCache()

 // For production with multiple instances:
 // redisCache := cache.NewRedisCache("localhost:6379", "", 0)

 // Initialize repositories
 productRepo := models.NewProductRepository()

 // Initialize handlers with the cache
 productHandler := handlers.NewProductHandler(memCache, productRepo)

 // Set up routes
 http.HandleFunc("/api/products", productHandler.GetProduct)

 // Start the server
 log.Println("Server starting on :8080")
 log.Fatal(http.ListenAndServe(":8080", nil))
}

Cache Invalidation(a.k.a. “The Hard Part”)

There’s a famous saying: “There are only two hard things in Computer Science: cache invalidation and naming things.”

Invalidation means making sure cached data isn’t stale. Here are some strategies:

Time-Based Invalidation

The simplest approach is to set an expiration time. We’ve already implemented this in our cache:

// Cache product for 15 minutes
cache.Set("product:123", product, 15*time.Minute)

Event-Based Invalidation

When data changes, explicitly invalidate the cache:

func (h *ProductHandler) UpdateProduct(w http.ResponseWriter, r *http.Request) {
 var product models.Product
 if err := json.NewDecoder(r.Body).Decode(&product); err != nil {
  http.Error(w, err.Error(), http.StatusBadRequest)
  return
 }

 // Update in database
 if err := h.productRepo.Update(product); err != nil {
  http.Error(w, err.Error(), http.StatusInternalServerError)
  return
 }

 // Invalidate cache
 h.cache.Delete("product:" + product.ID)

 w.WriteHeader(http.StatusOK)
}

Pattern-Based Invalidation

For more complex scenarios, you might need to invalidate multiple related cache entries:

// DeleteByPattern removes all items matching a pattern (for memory cache)
func (c *MemoryCache) DeleteByPattern(pattern string) {
 c.items.Range(func(key, value interface{}) bool {
  k := key.(string)
  // Simple pattern matching (in production, use regex)
  if strings.Contains(k, pattern) {
   c.items.Delete(key)
  }
  return true
 })
}

For Redis, you could use the SCAN command to find keys matching a pattern.

Common Pitfalls and How to Avoid Them

  1. Caching too much data: Be selective about what you cache. Caching everything can waste memory.
  2. Not setting expiration times: Always set reasonable TTLs to prevent stale data.
  3. Cache stampede: When many requests hit a missing cache key simultaneously, they all try to regenerate the cache. Use techniques like “cache warming” or “mutex locking” to prevent this.
  4. Forgetting to invalidate: Always update or invalidate cache when the underlying data changes.
  5. Serialization issues: Make sure your cached objects can be properly serialized/deserialized.

Here’s a simple mutex lock implementation to prevent cache stampedes:

// Add to MemoryCache struct
type MemoryCache struct {
 items sync.Map
 locks sync.Map
}

// GetOrSet gets from cache or sets if not found (prevents stampede)
func (c *MemoryCache) GetOrSet(key string, ttl time.Duration, generator func() (interface{}, error)) (interface{}, error) {
 // Try to get from cache first
 if value, found := c.Get(key); found {
  return value, nil
 }

 // Get or create a mutex for this key
 lockI, _ := c.locks.LoadOrStore(key, &sync.Mutex{})
 lock := lockI.(*sync.Mutex)

 // Lock to prevent multiple goroutines from generating the same value
 lock.Lock()
 defer func() {
  lock.Unlock()
  c.locks.Delete(key) // Clean up the mutex
 }()

 // Check cache again (another goroutine might have populated it)
 if value, found := c.Get(key); found {
  return value, nil
 }

 // Generate the value
 value, err := generator()
 if err != nil {
  return nil, err
 }

 // Store in cache
 c.Set(key, value, ttl)

 return value, nil
}

Real-World Use Cases

1. Product Catalog

E-commerce sites benefit tremendously from caching product details. Products don’t change frequently, but they’re viewed constantly.

2. User Authentication

Cache user sessions and permissions to avoid database lookups on every request.

// Check if user is authenticated
func (h *AuthHandler) Authenticate(next http.Handler) http.Handler {
 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  token := r.Header.Get("Authorization")
  if token == "" {
   http.Error(w, "Unauthorized", http.StatusUnauthorized)
   return
  }

  // Try to get user from cache
  cacheKey := "auth:" + token
  if cachedUser, found := h.cache.Get(cacheKey); found {
   // User found in cache
   r = r.WithContext(context.WithValue(r.Context(), "user", cachedUser))
   next.ServeHTTP(w, r)
   return
  }

  // Validate token and get user
  user, err := h.authService.ValidateToken(token)
  if err != nil {
   http.Error(w, "Unauthorized", http.StatusUnauthorized)
   return
  }

  // Cache user for future requests (30 minutes)
  h.cache.Set(cacheKey, user, 30*time.Minute)

  // Add user to request context
  r = r.WithContext(context.WithValue(r.Context(), "user", user))
  next.ServeHTTP(w, r)
 })
}

3. API Rate Limiting

Use cache to track and limit API requests per user:

func (h *RateLimitHandler) Limit(next http.Handler) http.Handler {
 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  clientIP := r.RemoteAddr
  cacheKey := "ratelimit:" + clientIP

  // Get current count
  count := 0
  if cachedCount, found := h.cache.Get(cacheKey); found {
   count = cachedCount.(int)
  }

  // Check if rate limit exceeded
  if count >= 100 { // 100 requests per minute
   http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
   return
  }

  // Increment count
  h.cache.Set(cacheKey, count+1, 1*time.Minute)

  // Process the request
  next.ServeHTTP(w, r)
 })
}

Conclusion

Implementing caching in your Go API doesn’t have to be complicated, but it can dramatically improve performance and user experience. Start with a simple in-memory cache for development, then graduate to Redis or another distributed cache when you need to scale.

Remember these key takeaways:

  1. Cache selectively — focus on expensive operations and frequently accessed data
  2. Always set expiration times to prevent stale data
  3. Have a solid invalidation strategy when data changes
  4. Monitor your cache hit/miss rates to ensure it’s effective

The best part about implementing caching in Go is that you can start small and incrementally improve as your application grows. Even a simple cache can provide significant performance benefits.

Enjoyed this post?

I write everything here for free — no paywall, no ads. If it helped you or saved you time, consider buying me a coffee☕. It really helps me keep writing and sharing more content like this. Thanks for reading! 🙌


메타데이터
post_id
fcd9ac88b618
slug
boosting-golang-apis-with-caching-strategies-examples-and-pitfalls-to-avoid-fcd9ac88b618
url
https://blog.stackademic.com/boosting-golang-apis-with-caching-strategies-examples-and-pitfalls-to-avoid-fcd9ac88b618
canonical_url
https://blog.stackademic.com/boosting-golang-apis-with-caching-strategies-examples-and-pitfalls-to-avoid-fcd9ac88b618
author_url
https://medium.com/@gane18
status
ok
fetched_at
2026-06-11 06:59:45