A service can become overloaded even when each individual request is reasonable. Imagine a popular product whose cached record expires. Fifty requests arrive almost together, all observe the same cache miss, and all start the same database query.
The problem is not ordinary parallelism. Those requests are doing duplicate work for the same result at the same time.
golang.org/x/sync/singleflight provides a small mechanism for suppressing that duplication inside one Go process. For a given key, one caller performs the work while concurrent callers for the same key wait and receive the same result. Calls using different keys can still perform their own work.
The useful mental model is not “a faster cache.” It is an in-flight rendezvous:
request A for product:42 ----> run load(42) ----> result
request B for product:42 ----- waits -----------^ same result
request C for product:42 ----- waits -----------^ same result
request D for product:99 ----> run load(99) independentlyThis article explains that model, shows where it fits around expensive reads, and covers the boundaries that matter in production: key correctness, errors, cancellation, caching, and multi-process deployments.
Start with the duplicate-work problem
Suppose a handler loads a product whenever its cache does not contain the requested ID:
func (s *Server) product(id string) (Product, error) {
if product, ok := s.cache.Get(id); ok {
return product, nil
}
product, err := s.store.LoadProduct(id)
if err != nil {
return Product{}, err
}
s.cache.Set(id, product)
return product, nil
}This can be correct for one caller and still behave poorly under concurrency.
If many goroutines miss the same key before the first database query finishes, each goroutine can call LoadProduct(id). The cache only helps after one of those calls has produced and stored a value. During the gap between the miss and the fill, duplicate work can accumulate.
That gap is where singleflight is useful.
A Group suppresses overlapping calls by key
A singleflight.Group forms a namespace of in-flight work. Its Do method accepts a string key and a function:
value, err, shared := group.Do(key, func() (any, error) {
return doExpensiveWork()
})For one key, Do makes sure only one execution of the function is in flight at a time. A duplicate caller waits for that execution and receives its value and error. The shared result reports whether the returned result was supplied to more than one caller.
A small example makes the behavior concrete:
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
"golang.org/x/sync/singleflight"
)
func main() {
var group singleflight.Group
var executions atomic.Int64
start := make(chan struct{})
var ready sync.WaitGroup
var done sync.WaitGroup
const callers = 5
ready.Add(callers)
done.Add(callers)
for i := 0; i < callers; i++ {
go func() {
defer done.Done()
ready.Done()
<-start
value, err, _ := group.Do("report:weekly", func() (any, error) {
executions.Add(1)
time.Sleep(50 * time.Millisecond)
return "ready", nil
})
if err != nil {
panic(err)
}
fmt.Println(value)
}()
}
ready.Wait()
close(start)
done.Wait()
fmt.Println("executions:", executions.Load())
}The synchronization only makes the example arrange overlapping calls. The important part is the shared key, "report:weekly". While its first function is still running, later calls with that key join the same in-flight call instead of starting another one.
Do not interpret singleflight as a promise that five calls made “around the same time” will always become one execution. Calls are combined only while an earlier call for the same key is still in flight. A call arriving after that work has completed starts a new execution.
Put the key around the work that is truly shareable
The key defines which calls are allowed to share a result. A bad key can therefore be a correctness bug, not merely a performance problem.
Suppose the result varies by product ID and locale:
func loadProduct(id, locale string) (Product, error)Using only the product ID as the singleflight key is unsafe if localized fields can differ:
key := "product:" + idA request for English data could join an in-flight request for Indonesian data and receive the wrong representation.
The key must include every input that changes the shareable result:
key := "product:" + id + ":locale:" + localeIn real code, choose an unambiguous encoding when components can contain separators. A small helper or structured serialization can make the mapping easier to review.
The rule is simple:
Two calls may use the same singleflight key only when it is correct for them to receive the same value and error from one execution.
Request IDs are usually the opposite of what you want. If every request gets a unique key, no calls collide and no duplicate work is suppressed.
Combine singleflight with cache-aside deliberately
Singleflight and caching solve different time windows.
A cache reuses a result after the work has completed. Singleflight reuses an execution while the work is still running.
They often work well together:
func (s *Server) product(id string) (Product, error) {
if product, ok := s.cache.Get(id); ok {
return product, nil
}
value, err, _ := s.loads.Do("product:"+id, func() (any, error) {
// Check again because another caller may have filled the cache
// before this function became the active loader.
if product, ok := s.cache.Get(id); ok {
return product, nil
}
product, err := s.store.LoadProduct(id)
if err != nil {
return nil, err
}
s.cache.Set(id, product)
return product, nil
})
if err != nil {
return Product{}, err
}
return value.(Product), nil
}The first cache check keeps ordinary hits away from singleflight entirely. The second check inside the function is useful when cache state can change between the outer miss and execution of the loader.
The type assertion is necessary because singleflight.Group.Do returns any. Keep that conversion close to the boundary so the rest of the application can continue using concrete types.
Singleflight does not store the successful value after the call finishes. If the cache is absent or still misses, a later request can execute the loader again.
Errors are shared too
Duplicate callers receive the same error produced by the active call.
That is often desirable. If ten concurrent requests all need the same record and the database lookup fails, running ten identical failing queries usually does not improve the outcome.
But error sharing changes the failure shape. One transient failure can be observed by every caller that joined that in-flight operation.
Do not add an immediate retry inside every waiting request without thinking through the effect. If all callers retry together, the service can replace one burst of duplicate work with another burst moments later.
If retries are appropriate, place them at a layer with an explicit retry policy: bounded attempts, backoff where useful, and knowledge of which failures are safe to retry. Singleflight is duplicate suppression, not a retry mechanism.
Do is not independently cancellable for each waiter
Do waits synchronously for the in-flight function to finish. Its API does not accept a context.Context.
A tempting pattern is to capture one request’s context in the function:
value, err, _ := group.Do(key, func() (any, error) {
return fetch(ctx, id)
})This requires a deliberate ownership decision. The function belongs to the shared in-flight call, but ctx may belong to whichever request happened to become the first caller. If that request is canceled, the shared operation may fail even though other callers still want the result.
There is no universal policy for this. Choose based on what the work represents.
For work that should live independently of any one request, create a context with an appropriate service-level timeout rather than borrowing the first caller’s cancellation lifetime. For work that should stop when its initiating request ends, accepting that cancellation may also affect joined callers can be correct.
The important point is to make the lifetime explicit instead of assuming singleflight provides per-caller cancellation semantics.
DoChan lets callers wait with select
DoChan starts or joins the keyed work and returns a receive-only channel of singleflight.Result:
resultCh := group.DoChan(key, func() (any, error) {
return fetchProduct(id)
})
select {
case result := <-resultCh:
if result.Err != nil {
return Product{}, result.Err
}
return result.Val.(Product), nil
case <-ctx.Done():
return Product{}, ctx.Err()
}This lets an individual caller stop waiting when its context is canceled.
That does not mean canceling the waiter cancels the shared function. The in-flight work has its own execution lifetime. Other callers may still be waiting for it, and the package cannot assume one caller owns the operation.
Also note an API detail that is easy to misuse: the channel returned by DoChan receives a result but is not closed. Wait for the result you need; do not write logic that depends on ranging until channel closure.
Different keys are different units of work
Singleflight is not a general concurrency limit.
These calls use different keys:
product:41
product:42
product:43Each key can have its own in-flight function. If thousands of distinct keys miss at once, singleflight can still allow thousands of expensive operations to begin.
If the real requirement is “never run more than 20 database rebuilds concurrently,” use a concurrency-limiting mechanism as well. A semaphore, worker pool, or another bounded-execution design addresses that problem.
The distinction is important:
singleflight: collapse duplicate work for the same key
limiter: bound how much work may run at once
cache: reuse completed results over timeA production system may use all three because they protect different failure modes.
A Group only coordinates callers that share that Group
A Group is an in-memory Go value. Calls must reach the same group instance to coordinate.
If every request constructs a new group, suppression disappears:
func load(id string) (Product, error) {
var group singleflight.Group // new group for this invocation
// ...
}Keep the group at the lifetime where callers that should coordinate can actually share it, such as a service struct:
type Server struct {
store Store
cache Cache
loads singleflight.Group
}The same boundary applies across processes. Ten service replicas have ten independent in-memory groups. They can each execute one load for the same key at the same time.
That may still be a major improvement because each replica suppresses its own local duplicates. It is not distributed coordination. If the backend requires cross-process suppression, design that separately with a mechanism whose failure and ownership semantics fit the system.
Forget changes what future callers join
Group.Forget(key) tells the group to forget the current association for a key. A future Do call for that key will not wait for the earlier in-flight call; it can start a new function execution.
That is a specialized escape hatch, not a normal cleanup step. Ordinary completed calls are removed from the group’s in-flight bookkeeping without callers needing to call Forget.
Use Forget only when it is actually correct for a newer caller to stop joining the older operation. After forgetting, two executions for the same logical key can overlap, so any assumption that singleflight serializes that key no longer holds during that period.
If your goal is to invalidate a cached value, invalidate the cache. Cache invalidation and forgetting an in-flight call are different operations.
Do not use singleflight as a mutex
Because Do allows only one in-flight function per key, it can look like a keyed lock. That mental model is incomplete.
A mutex protects a critical section so each caller can perform its own operation in sequence. Singleflight intentionally does something different: duplicate callers do not perform their functions at all. They receive the first call’s result.
Consider account updates. Two callers both want to modify account 42, but they carry different changes. Giving both operations the key "account:42" would not serialize both updates. One update function could run while the other caller merely receives its result. The second mutation would be lost.
Singleflight fits shareable reads and computations. Use locking, transactions, queues, or other serialization mechanisms when every operation must actually execute.
Measure whether duplicate suppression is helping
Singleflight is most valuable when requests genuinely collide on a relatively small set of expensive keys.
Useful application-level observations include:
- how often expensive loaders execute;
- how often results are shared;
- loader latency and error rate;
- which logical resources produce hot-key bursts;
- whether backend load falls during cache misses or refreshes.
The shared boolean can contribute to that picture, but avoid attaching raw user-controlled or extremely high-cardinality keys to metrics labels. Aggregate at a level that remains operationally useful.
Also measure waiting latency. Suppressing duplicate work protects the backend, but joined callers still wait for the active execution. A slow dependency remains slow even when only one copy of the request reaches it.
When singleflight is the right tool
Singleflight is a strong fit when all of these are true:
- concurrent calls frequently request the same logical result;
- one execution’s value and error are valid for every joined caller;
- the work is expensive enough that suppressing duplicates matters;
- process-local coordination is sufficient, or is useful as one layer of a larger design.
Typical examples include rebuilding an expired cache entry, loading shared metadata, refreshing a locally shared configuration value, or computing an expensive derived representation.
Skip it when calls rarely share keys, when every operation has distinct side effects, when each caller must execute independently, or when the actual problem is a global concurrency limit.
Keep the mental model narrow
singleflight.Group does one job: it coalesces overlapping function calls that use the same key. The first call performs the work; duplicate callers wait for and share its result. Once that call is no longer in flight, a later call can perform the work again.
That narrow behavior is what makes the tool useful. It complements caching rather than replacing it, does not provide distributed coordination, does not automatically solve caller cancellation, and must not be used where every operation needs to execute.
When you can define a correct shareable key and the expensive work is duplicated only because requests overlap in time, singleflight turns a burst of identical work into one execution without unnecessarily serializing unrelated keys.