Why is your Go application slow? You've probably forgotten about caching
You wrote the perfect Go application. You're proud of your goroutines, channels, and fast compiler. But when the load reaches 1000 RPS, everything starts to creak. The database crashes, the API responds in 5 seconds, users leave for competitors.
Sound familiar? Welcome to the world where caching in Go is essential. In this article, we'll break down how to turn your tortoise-like application into a cheetah. No fluff, with real examples and ready-made solutions.
By the way, my name is Python, and this is PythonLib. Yes, I know we're talking about Go. But good code knows no language boundaries, and we write useful articles about everything. Let's go!
What is caching and why do you need it in Go?
Data caching is storing frequently requested data in fast storage (RAM, Redis, files) so you don't have to hit the slow source (database, external API, file system) every time.
Go is a language for high-load systems. But even in Go, you can write slow code if you don't use caching. Imagine: you query PostgreSQL for a list of countries 1000 times per second. Instead of 1000 database queries, you can make just 1, and serve the data from in-memory cache Go the other 999 times.
Result: response time drops from 50 ms to 0.5 ms. A 100x difference. That's not magic, it's good architecture.
When is caching mandatory?
- High load (1000+ requests per second)
- Slow external APIs (payment gateways, weather, exchange rates)
- Heavy computations (reports, analytics)
- Frequently requested reference data (city lists, product categories)
Caching strategies: choosing the right one
To avoid turning caching into an anti-pattern, you need to choose the right caching strategy. Here are the main options that work in Go:
1. Cache-Aside (Lazy Caching)
The most popular pattern. The application first checks the cache. If the data is not there (cache miss), it goes to the database, saves the result to the cache, and returns it to the client.
func GetUser(cache *redis.Client, db *sql.DB, userID int) (*User, error) { // Try to get from cache val, err := cache.Get(ctx, "user:"+strconv.Itoa(userID)).Result() if err == nil { user := &User{} json.Unmarshal([]byte(val), user) return user, nil }
// Cache miss — go to DB user, err := db.Query("SELECT * FROM users WHERE id = ?", userID) if err != nil { return nil, err }
// Save to cache for 1 hour data, _ := json.Marshal(user) cache.Set(ctx, "user:"+strconv.Itoa(userID), data, time.Hour)
return user, nil}2. Write-Through
Data is written to both the cache and the database simultaneously. Pro: the cache is always up-to-date. Con: writes are slightly slower.
3. TTL (Time To Live)
The simplest way of cache invalidation. Data lives for a fixed time, then is automatically deleted.
// Cache for 5 minutescache.Set(ctx, "key", value, 5*time.Minute)Implementing an in-memory cache in Go from scratch
Sometimes you don't want to set up Redis for just a couple of keys. For such cases, in-memory cache Go works great. Let's write a simple but functional version:
package cache
import ( "sync" "time")
type Item struct { Value interface{} Expiration int64}
type Cache struct { items map[string]Item mu sync.RWMutex}
func New() *Cache { c := &Cache{ items: make(map[string]Item), } // Start a goroutine to clean up expired entries go c.cleanup() return c}
func (c *Cache) Set(key string, value interface{}, ttl time.Duration) { c.mu.Lock() defer c.mu.Un