Skip to content
Muhammet Şafak
tr
Asked by: Emir Answered:

Is singleflight the right way to prevent a cache stampede in my Go API?


Question

In a Go API I wrote, when a hot Redis key expires, hundreds of concurrent requests all hit Postgres at once and recompute the same value; the classic cache stampede. What I want is for only one request to rebuild the value when the key empties, and the rest to share the result. `singleflight` looks like the right tool for this, but is it enough on its own in production, or am I missing something?

Answer

Short answer

Yes, singleflight is the right tool for in-process stampede collapse — but it only dedupes within a single process.

Context

Across many pods you still get one rebuild per pod, so if that matters, pair it with stale-while-revalidate or probabilistic early expiration.

Why

  1. What singleflight actually does. golang.org/x/sync/singleflight collapses concurrent calls with the same key into a single execution; the rest wait and share the result. That is exactly your “only one should rebuild” requirement — within one process.
    var g singleflight.Group
    
    func (s *Service) Get(ctx context.Context, key string) (Value, error) {
        if v, ok := s.cache.Get(key); ok {
            return v, nil
        }
        v, err, _ := g.Do(key, func() (any, error) {
            val, err := s.loadFromDB(ctx, key) // only one runs
            if err != nil {
                return nil, err
            }
            s.cache.Set(key, val, ttl)
            return val, nil
        })
        if err != nil {
            return Value{}, err
        }
        return v.(Value), nil
    }
  2. The per-pod caveat. With N replicas behind a load balancer you get up to N concurrent rebuilds, not one. For most apps N is small (say 10) and 10 queries is fine. If it isn’t, you need a cross-process lock (Redis SETNX with a TTL): only one pod rebuilds and the rest wait or serve stale.
  3. Beware the shared-error and shared-panic trap. singleflight shares the result AND the error with every waiter — a single failure fails everyone in that batch. Use DoChan with a context so a slow rebuild can’t block callers forever, and consider Forget so a transient error isn’t cached.

What to do

  1. Consider serving stale while rebuilding. The lowest-latency pattern is stale-while-revalidate: keep a soft TTL, return the stale value instantly, and trigger a single background refresh (singleflight-guarded). Users never wait on the rebuild at all.
  2. Probabilistic early expiration (XFetch) reduces the synchronized expiry: each request rolls the dice on whether to refresh slightly before the TTL, so the herd is less likely to form — there’s no guarantee it won’t. It complements singleflight rather than replacing it.
  3. Don’t forget the DB side. Even a single rebuild query must be fast and indexed. Stampede protection hides a slow query; it doesn’t fix it.

Bottom line: personally I’d start with singleflight plus stale-while-revalidate — that combination kills the stampede for the vast majority of apps and keeps latency flat. I’d only add a Redis distributed lock once I’d measured that per-pod rebuilds were actually hurting Postgres. In short: reach for the simplest layer your replica count justifies, and no more.

Related Reading

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

More Questions

All questions

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind