A cache stampede occurs when concurrent misses cause redundant work against the same dependency. Synchronized expiration is one trigger, but eviction, a cold deployment, or cache unavailability can create the same burst. A single popular key can be enough. The useful question is how many rebuilds reach the source while one result is still being computed.
Coalesce work within its actual boundary
Go’s singleflight Group gives one key at most one in-flight function execution within that Group. Duplicate callers receive the same result, including the same error. Separate Groups do not coordinate, even within one process, and separate service instances normally have separate Groups. It is neither a distributed lock nor a cache of completed values.
Keep the Group shared across requests, and make its key identify equivalent work. Mixing tenants, permissions, currencies, or representations under one key can share the wrong result. In a cache-backed loader, check the cache again inside the shared function, fetch only if still necessary, and publish a successful value before returning. Singleflight itself does none of those storage operations.
Spread deadlines without changing the promise
Jitter spreads expiration across different entries or independently maintained copies. It does not prevent all callers of one expired hot key from colliding. If a maximum TTL is a freshness requirement, randomly shortening lifetimes within a chosen bound avoids silently exceeding it; adding arbitrary positive jitter changes that requirement. There is no universally correct percentage or number of seconds.
Early refresh and permitted stale responses can keep callers from waiting, but they require explicit application rules. A last-known public catalog may be acceptable temporarily; a revoked permission or current balance may not be. Coalescing reduces duplicate work without deciding whether old data is safe, and a cold cache still needs a source response.
When it breaks
A stuck shared loader can hold many waiters. Bound the loader’s dependency calls and give callers their own waiting deadlines. DoChan returns a result channel, not a cancellation mechanism: one caller abandoning its wait does not automatically stop the underlying work. Forget allows a new execution for the key; it does not cancel the old execution, so it can reintroduce overlapping loads.
Errors are shared only while that execution is in flight. Immediate retries after it finishes can create successive waves against a failing dependency. Use a retry and admission policy matched to that dependency; cacheable absence and transient failure need different treatment. Likewise, a fleet of one hundred local Groups can still create one hundred rebuilds. Do not describe one-process suppression as a global origin-load bound.
Check it quickly
Use Go 1.25 or newer; this exercise was run with Go 1.27.0 and x/sync v0.22.0. Create an isolated module below, put the Go block in main.go there, and run go run -race main.go. Dependency installation requires the network. The experiment starts at the cold-miss boundary and invokes counted loader functions, not a real database, cache server, or timed TTL implementation.
stampede_lab=$(mktemp -d)
cd "$stampede_lab"
go mod init example.invalid/stampede-lab
go get golang.org/x/sync@v0.22.0
package main
import (
"errors"
"fmt"
"sync"
"sync/atomic"
"golang.org/x/sync/singleflight"
)
const callers = 12
func require(ok bool) {
if !ok {
panic("unexpected result")
}
}
func wave(groups []*singleflight.Group, failure error) int32 {
var calls atomic.Int32
gate := make(chan struct{})
results := make([]<-chan singleflight.Result, callers)
load := func() (any, error) {
calls.Add(1)
<-gate
if failure != nil {
return nil, failure
}
return "catalog-v1", nil
}
for i := range results {
results[i] = groups[i%len(groups)].DoChan("catalog", load)
}
close(gate)
for _, result := range results {
r := <-result
require(r.Shared && r.Err == failure)
if failure == nil {
require(r.Val == "catalog-v1")
} else {
require(r.Val == nil)
}
}
return calls.Load()
}
func main() {
var plain atomic.Int32
var workers sync.WaitGroup
gate := make(chan struct{})
for range callers {
workers.Add(1)
go func() {
defer workers.Done()
plain.Add(1)
<-gate
}()
}
close(gate)
workers.Wait()
require(plain.Load() == callers)
fmt.Println("without coalescing: 12 loader calls")
group := new(singleflight.Group)
require(wave([]*singleflight.Group{group}, nil) == 1)
require(wave([]*singleflight.Group{group}, nil) == 1)
fmt.Println("one Group: 1 call per wave; completed results are not cached")
other := new(singleflight.Group)
require(wave([]*singleflight.Group{group, other}, nil) == 2)
fmt.Println("two independent Groups: 2 loader calls")
failure := errors.New("origin unavailable")
require(wave([]*singleflight.Group{group}, failure) == 1)
require(wave([]*singleflight.Group{group}, nil) == 1)
fmt.Println("one error reaches 12 callers; next wave starts a new load")
}
The baseline executes twelve loaders. Each single-Group wave executes one, and running a second wave on the same Group executes another: finished results are not retained. Splitting the twelve callers between two Groups produces two executions. A failing wave sends the same error to all twelve callers; the next successful wave starts fresh rather than retrieving a cached failure.
The gate keeps the shared load pending until every DoChan call has registered, so the test does not depend on sleeps or favorable scheduling. Shared is true for all results in these joined waves, including the initiating caller’s result. Receive exactly one result from each channel; DoChan does not close those channels, so ranging over one would wait indefinitely.
For a service-level follow-up, measure loader executions, waiting callers, latency, and failures during a deliberately cold test in an isolated environment. Include multiple instances and source timeouts, not just a high warm-cache hit ratio. The race-detector run validates this program’s observed execution, not every possible race in a production cache implementation.
Further reading
- Cache headers are a contract with two clocks
- Validators decide whether a stored copy is still the answer
Sources
- Go x/sync v0.22.0: singleflight, Group scope, DoChan, shared errors, and Forget, checked 2026-09-07.
- AWS Builders’ Library: caching challenges and strategies, cold caches, request coalescing, failure behavior, and empirical expiration choices, checked 2026-09-07.