REST APIs that don't cache are burning CPU and database connections on identical work. Once traffic picks up, you feel it fast — latency climbs, the database starts choking, and horizontal scaling just defers the problem. Here's a walkthrough of three caching patterns I applied to a Go service that brought p99 response time from ~420ms down to ~165ms.
The Problem: Too Many Identical Database Round-Trips
Every endpoint was going to the database even when nothing had changed. Cache hit rate was 0%. Profiling was unambiguous: 70% of request time was in SQL queries for rows that hadn't changed in hours. The fix wasn't a faster database — it was not hitting the database at all for identical reads.
Pattern 1: In-Process TTL Cache for Hot Read Paths
Start simple. A generic in-process cache with TTL covers most cases and has zero external dependencies.
package cache
import (
"sync"
"time"
)
type entry[V any] struct {
value V
expiresAt time.Time
}
type TTL[K comparable, V any] struct {
mu sync.RWMutex
items map[K]entry[V]
ttl time.Duration
}
func New[K comparable, V any](ttl time.Duration) *TTL[K, V] {
c := &TTL[K, V]{
items: make(map[K]entry[V]),
ttl: ttl,
}
go c.runEviction()
return c
}
func (c *TTL[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.items[key]
if !ok || time.Now().After(e.expiresAt) {
var zero V
return zero, false
}
return e.value, true
}
func (c *TTL[K, V]) Set(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = entry[V]{value: value, expiresAt: time.Now().Add(c.ttl)}
}
func (c *TTL[K, V]) runEviction() {
ticker := time.NewTicker(30 * time.Second)
for range ticker.C {
c.mu.Lock()
now := time.Now()
for k, e := range c.items {
if now.After(e.expiresAt) {
delete(c.items, k)
}
}
c.mu.Unlock()
}
}
Plug this into any handler layer. For product catalogs, config data, or user preferences where a few seconds of staleness is acceptable, this alone cut database query volume by 45% in my service. The generics make it type-safe without any interface{} juggling.
Pattern 2: singleflight to Prevent Cache Stampedes
In-process TTL caches have a well-known failure mode: when a key expires under burst traffic, dozens of goroutines simultaneously miss the cache and pile into the database. This is a cache stampede, and it can take a service down just when it's under the most pressure.
The standard Go fix is golang.org/x/sync/singleflight:
package handler
import (
"context"
"encoding/json"
"time"
"golang.org/x/sync/singleflight"
"yourapp/cache"
"yourapp/db"
)
var (
productCache = cache.New[string, []byte](5 * time.Minute)
sfGroup singleflight.Group
)
func GetProductsByCategory(ctx context.Context, categoryID string) ([]Product, error) {
if cached, ok := productCache.Get(categoryID); ok {
var products []Product
if err := json.Unmarshal(cached, &products); err == nil {
return products, nil
}
}
result, err, _ := sfGroup.Do(categoryID, func() (interface{}, error) {
products, err := db.FetchProductsByCategory(ctx, categoryID)
if err != nil {
return nil, err
}
if data, marshalErr := json.Marshal(products); marshalErr == nil {
productCache.Set(categoryID, data)
}
return products, nil
})
if err != nil {
return nil, err
}
return result.([]Product), nil
}
sfGroup.Do deduplicates concurrent calls for the same key. All waiting goroutines get the result of the single in-flight fetch. Under a traffic spike, this turns hundreds of parallel DB queries into one. The third return value (shared bool) tells you whether the result was actually shared — useful for metrics.
Pattern 3: Tiered Caching with Redis for Multi-Instance Deployments
In-process caches don't survive restarts and don't share state across instances. For data that's expensive to generate and shared across users — aggregations, dashboards, leaderboard rankings — a Redis tier makes sense.
The access pattern is: check in-process cache → check Redis → fetch from DB → write back to both.
package cache
import (
"context"
"encoding/json"
"time"
"github.com/redis/go-redis/v9"
)
type Tiered[V any] struct {
local *TTL[string, V]
remote *redis.Client
prefix string
}
func NewTiered[V any](local *TTL[string, V], rdb *redis.Client, prefix string) *Tiered[V] {
return &Tiered[V]{local: local, remote: rdb, prefix: prefix}
}
func (t *Tiered[V]) Get(ctx context.Context, key string) (V, bool) {
if v, ok := t.local.Get(key); ok {
return v, true
}
data, err := t.remote.Get(ctx, t.prefix+":"+key).Bytes()
if err == nil {
var v V
if json.Unmarshal(data, &v) == nil {
t.local.Set(key, v)
return v, true
}
}
var zero V
return zero, false
}
func (t *Tiered[V]) Set(ctx context.Context, key string, value V, ttl time.Duration) error {
t.local.Set(key, value)
data, err := json.Marshal(value)
if err != nil {
return err
}
return t.remote.Set(ctx, t.prefix+":"+key, data, ttl).Err()
}
The local layer absorbs the hot tail. Redis handles cold hits and cross-instance consistency. You get sub-millisecond local hits on the most-requested keys without making each instance an island.
What Not to Cache
A few anti-patterns worth naming:
User-specific write-heavy data. Cache invalidation becomes a correctness problem. If a user updates their profile, your cache has to know about it. The logic complexity often outweighs the gain.
Paginated results backed by changing data. You'll serve inconsistent pages — page 1 reflects state at T0, page 2 reflects state at T0+5min.
Anything requiring real-time accuracy. Inventory counts, account balances, seat availability. Cache TTL should express how much staleness is acceptable, not how fast you want things to feel. A 5-minute TTL on data that must be accurate to the second is a bug.
Security Note on Caching
One thing that's easy to overlook: cached responses can expose data to the wrong user if your authorization layer isn't checked on every request. Caching at the handler level can bypass per-user access checks if you're not careful about cache key design. Always include user or tenant identifiers in cache keys for user-scoped data. I keep a security hardening checklist for Go APIs that covers these patterns if you want a reference.
The Takeaway
The 60% improvement came from three layers applied in sequence:
- In-process TTL cache on the hottest read endpoints — immediate, zero dependencies, visible impact.
- singleflight to collapse burst cache misses — critical under traffic spikes.
- Redis tiered cache for shared aggregations — consistent across instances, survives restarts.
None of this required a rewrite. Profile first to confirm where the time actually goes, then apply the simplest pattern that solves it.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)