go 47 lines · 1 tab

Singleflight cache fill to prevent thundering herd

Leah Thompson Jan 2026
1 tab
package cache

import (
  "context"
  "sync"
  "time"

  "golang.org/x/sync/singleflight"
)

type item struct {
  v       string
  expires time.Time
}

type Cache struct {
  mu    sync.Mutex
  data  map[string]item
  group singleflight.Group
}

func New() *Cache { return &Cache{data: make(map[string]item)} }

func (c *Cache) Get(ctx context.Context, key string, ttl time.Duration, loader func(context.Context) (string, error)) (string, error) {
  c.mu.Lock()
  if it, ok := c.data[key]; ok && time.Now().Before(it.expires) {
    v := it.v
    c.mu.Unlock()
    return v, nil
  }
  c.mu.Unlock()

  v, err, _ := c.group.Do(key, func() (any, error) {
    val, err := loader(ctx)
    if err != nil {
      return "", err
    }
    c.mu.Lock()
    c.data[key] = item{v: val, expires: time.Now().Add(ttl)}
    c.mu.Unlock()
    return val, nil
  })
  if err != nil {
    return "", err
  }
  return v.(string), nil
}
1 file · go Explain with highlit

When a cache key expires, it’s easy for a burst of requests to stampede the database. I use singleflight.Group to ensure only one goroutine performs the expensive fill per key while others wait for the shared result. This doesn’t replace proper TTLs or circuit breakers, but it smooths the “sawtooth” load pattern you get with synchronized expirations. The code is intentionally small: check the cache, call group.Do on miss, and set the cache on success. The other detail I care about is error behavior: if the fill fails, I don’t poison the cache, and I return the underlying error so the caller can decide whether to serve stale data. It’s one of the highest ROI concurrency primitives in Go.


Related snips

Share this code

Here's the card — post it anywhere.

Singleflight cache fill to prevent thundering herd — share card
Link copied