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.
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
- Caching too much data: Be selective about what you cache. Caching everything can waste memory.
- Not setting expiration times: Always set reasonable TTLs to prevent stale data.
- 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.
- Forgetting to invalidate: Always update or invalidate cache when the underlying data changes.
- 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:
- Cache selectively — focus on expensive operations and frequently accessed data
- Always set expiration times to prevent stale data
- Have a solid invalidation strategy when data changes
- 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