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
- What singleflight actually does.
golang.org/x/sync/singleflightcollapses 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 } - 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
SETNXwith a TTL): only one pod rebuilds and the rest wait or serve stale. - 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
DoChanwith a context so a slow rebuild can’t block callers forever, and considerForgetso a transient error isn’t cached.
What to do
- 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.
- 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.
- 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.