The problem: thundering herds
You've built a high-traffic API. A cache key expires, and suddenly 100 goroutines slam your database with identical queries. Each one misses the cache, each one fetches the same data, and each one writes it back. This is a thundering herd—and it's wasteful. Singleflight in Go: a practical guide for developers teaches you how to eliminate this waste by ensuring duplicate requests share a single result.
The golang.org/x/sync/singleflight package solves this elegantly. When multiple goroutines ask for the same thing at the same time, only one does the work. The rest wait for that single result and use it. On a cache miss, you fetch the data once, not 100 times.
Understanding singleflight
Singleflight is a synchronization primitive that deduplicates concurrent work. It lives in the standard library's experimental sync package and is production-ready across countless services handling millions of requests.
The core idea: a Group holds a map of in-flight requests. When you call Do(key, fn), it checks if that key is already being processed. If yes, it waits for the result. If no, it runs your function and shares the result with all waiting callers. All of this happens without explicit locking on your side—singleflight handles the synchronization.
This is different from a simple mutex-protected cache. With a mutex, you'd lock, check the cache, unlock, do the work, lock again, and write back. With singleflight, you check whether work is in flight and join an existing request if it is. The mechanism is simpler and faster for the duplicate-request case.
Duplicate calls share one result
Let's walk through a realistic scenario: a user service that fetches profile data from a database. Without singleflight, a cache miss becomes a bottleneck.
package main
import (
"context"
"fmt"
"sync"
"time"
)
type UserService struct {
// Without singleflight: each miss queries the database
mu sync.RWMutex
cache map[string]string
}
func (us *UserService) GetUserBad(userID string) (string, error) {
us.mu.RLock()
if val, ok := us.cache[userID]; ok {
us.mu.RUnlock()
return val, nil
}
us.mu.RUnlock()
// All 100 goroutines get here simultaneously on cache miss
time.Sleep(100 * time.Millisecond) // Simulate DB query
result := fmt.Sprintf("User %s data", userID)
us.mu.Lock()
us.cache[userID] = result
us.mu.Unlock()
return result, nil
}With singleflight, we deduplicate that work:
package main
import (
"context"
"fmt"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
type UserService struct {
mu sync.RWMutex
cache map[string]string
singleflight.Group
}
func (us *UserService) GetUser(userID string) (string, error) {
us.mu.RLock()
if val, ok := us.cache[userID]; ok {
us.mu.RUnlock()
return val, nil
}
us.mu.RUnlock()
// All 100 goroutines call Do() with the same key
// Only the first one runs fn(). The rest wait.
val, err, _ := us.Do(userID, func() (interface{}, error) {
time.Sleep(100 * time.Millisecond) // Simulate DB query
result := fmt.Sprintf("User %s data", userID)
us.mu.Lock()
us.cache[userID] = result
us.mu.Unlock()
return result, nil
})
if err != nil {
return "", err
}
return val.(string), nil
}The signature of Do returns three values: the result, the error, and a shared flag. The shared flag is true if the result came from another caller's in-flight request. This is useful for metrics—you can track how many requests were deduplicated.
On a cache miss with 100 concurrent requests, the database query runs exactly once. The other 99 goroutines block on the same channel and receive the same result immediately after. Your database and application are under control.
Perfect for cache misses
Cache misses are where singleflight shines. They're also the hardest scenario to defend against without it.
Consider a popular user ID that hasn't been fetched recently. The cache TTL expires. Now 50 requests hit your API for that user within the same millisecond. Without singleflight, you query the database 50 times. With it, you query once and reuse the result.
This becomes critical during:
- Cache invalidation events: when a key expires and traffic is high.
- Cold starts: when the cache is empty and requests are bursty.
- Downstream outages: when your data source is slow, the window of duplicate requests widens, and singleflight prevents cascading load.
Real example: an e-commerce checkout service caches product prices for 5 minutes. At minute 5, the cache expires. If your product service gets 200 checkouts per second, all 1000 requests in the next 5 seconds might hit the same expired key. Singleflight ensures you fetch the price once, not 1000 times.
The practical benefit is two-fold: reduced database load and faster response times for the duplicate requests (they wait microseconds instead of 100ms for the database round trip).
Combine with TTL caches
Singleflight works best as part of a layered caching strategy. Use it alongside a TTL cache (like Redis or an in-memory map with expiration) for maximum resilience.
The pattern:
- Check the TTL cache (fast, local).
- On miss, use singleflight to deduplicate the fetch.
- Inside the singleflight function, fetch from the source (database, API, etc.).
- Write the result back to the TTL cache.
- Return to the caller.
Here's a complete example using a simple in-memory cache with expiration:
package main
import (
"context"
"fmt"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
type CacheEntry struct {
Value interface{}
Expiry time.Time
}
type CachedUserService struct {
mu sync.RWMutex
cache map[string]CacheEntry
sfg singleflight.Group
}
func (cs *CachedUserService) GetUser(ctx context.Context, userID string) (string, error) {
// Step 1: Check cache
cs.mu.RLock()
if entry, ok := cs.cache[userID]; ok && time.Now().Before(entry.Expiry) {
cs.mu.RUnlock()
return entry.Value.(string), nil
}
cs.mu.RUnlock()
// Step 2: Singleflight deduplicates on miss
val, err, shared := cs.sfg.Do(userID, func() (interface{}, error) {
// Step 3: Fetch from source (database, API, etc.)
result, err := cs.fetchFromDB(ctx, userID)
if err != nil {
return nil, err
}
// Step 4: Write to cache with TTL
cs.mu.Lock()
cs.cache[userID] = CacheEntry{
Value: result,
Expiry: time.Now().Add(5 * time.Minute),
}
cs.mu.Unlock()
return result, nil
})
if err != nil {
return "", err
}
// Step 5: Return
if shared {
fmt.Printf("Deduplicated request for user %s\n", userID)
}
return val.(string), nil
}
func (cs *CachedUserService) fetchFromDB(ctx context.Context, userID string) (string, error) {
// Simulate database query
time.Sleep(100 * time.Millisecond)
return fmt.Sprintf("User %s profile", userID), nil
}This pattern is robust. The cache provides fast hits for recent requests. Singleflight handles the miss window. The shared flag lets you emit metrics about deduplication efficiency.
One nuance: if your singleflight function modifies shared state (like the cache), be careful with race conditions. In the example above, we lock around the cache write. Multiple callers receive the same result, but only one performs the write—no corruption.
Common mistakes
Mistake 1: Forgetting to handle the error case. If the singleflight function returns an error, all callers get that error. They don't retry. If your source is temporarily unavailable, you might propagate a transient error to 100 callers. Consider retry logic or fallback caches.
Mistake 2: Reusing the Group across unrelated keys. Singleflight is designed for this—different keys are independent. But if you use the wrong key (e.g., the same key for different data), you'll get wrong results. Ensure your key is specific to the data you're fetching.
Mistake 3: Not cleaning up completed keys. The Group's internal map grows over time as you call Do with different keys. In long-running services, this can leak memory. Use Forget(key) after significant time has passed or when you're confident no more callers are waiting. Alternatively, create a new Group periodically or use a wrapper that manages this.
Mistake 4: Blocking callers indefinitely. If your singleflight function deadlocks or hangs, all waiting callers block forever. Use context with timeouts in your source fetch to avoid this.
// Good: add timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
val, err, _ := sfg.Do(key, func() (interface{}, error) {
// This function will respect the timeout
return fetchWithContext(ctx)
})Key takeaways
- Singleflight deduplicates concurrent requests for the same resource, eliminating thundering herds during cache misses.
- Use
Group.Do(key, fn)to run a function once for a given key, with all other callers waiting for the same result. - Combine singleflight with a TTL cache for a complete solution: fast hits and resilient misses.
- Always handle errors from singleflight—they propagate to all callers.
- Clean up keys with
Forgetin long-running services to prevent memory leaks. - Wrap source fetches with context timeouts to avoid blocking callers indefinitely.
- The shared flag tells you how many requests were deduplicated—use it for observability.






Comments
Loading comments…