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

> Start with singleflight plus stale-while-revalidate, and add a Redis distributed lock only once per-pod rebuilds measurably hurt Postgres.

- Asked: 2026-10-24
- Answered: 2026-10-30
- Asked by: Emir
- Tags: Go, Concurrency, Caching
- Source: https://muhammetsafak.com/just-ask/singleflight-right-way-prevent-cache-stampede-go-api/
- Language: en-US
- Author: Muhammet Şafak

---
**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?


## 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](https://muhammetsafak.com/blog/concurrency-in-go-goroutines-and-channels-in-practice/) into a single execution; the rest wait and share the result. That is exactly your "only one should rebuild" requirement — within one process.
   ```go
   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

- [Concurrency in Go: goroutines and channels in practice](https://muhammetsafak.com/blog/concurrency-in-go-goroutines-and-channels-in-practice/) — Blog
- [Does my channel buffer size choice hide backpressure problems, and how do I decide it?](https://muhammetsafak.com/just-ask/channel-buffer-size-choice-hide-backpressure-problems-decide/) — Just Ask
- [Should I run several dependent operations with errgroup so the first error cancels the rest?](https://muhammetsafak.com/just-ask/should-i-run-several-dependent-operations-with-errgroup-so-the-first/) — Just Ask
- [How do I propagate context cancellation correctly through nested service calls?](https://muhammetsafak.com/just-ask/how-do-i-propagate-context-cancellation-correctly-through-nested-service-calls/) — Just Ask
