Go concurrency patterns I use in production services
- Go
- Concurrency
- Goroutines
- errgroup
- singleflight
Go makes starting concurrent work trivially easy — one keyword. That is exactly why production Go services leak goroutines, run unbounded fan-outs, and race on shared maps. These are the patterns I reach for when writing services that handle a lot of traffic, such as a JWT authorizer that sits in front of other services.
Every goroutine needs a way to stop
A goroutine with no exit path is a memory leak waiting for traffic. Pass a context.Context into anything that blocks, and select on ctx.Done().
func poll(ctx context.Context, every time.Duration, fn func(context.Context)) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return // caller cancelled or the request ended
case <-t.C:
fn(ctx)
}
}
}If you start a goroutine and can't say what makes it return, don't start it yet.
Bound your fan-out with errgroup
Fanning out to call five services in parallel is fine. Fanning out to call one service for each of 10,000 items is an outage. golang.org/x/sync/errgroup gives you cancellation on the first error and a concurrency limit.
func fetchAll(ctx context.Context, ids []string) ([]Profile, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(16) // never more than 16 requests in flight
out := make([]Profile, len(ids))
for i, id := range ids {
g.Go(func() error {
p, err := getProfile(ctx, id)
if err != nil {
return err // cancels ctx for the others
}
out[i] = p // each goroutine writes its own index: no lock needed
return nil
})
}
return out, g.Wait()
}Since Go 1.22 each loop iteration has its own i and id, so capturing them in the closure is safe.
Channels have owners
Most channel bugs — panics on send to a closed channel, deadlocks on a channel nobody closes — come from unclear ownership. My rule:
- The goroutine that creates a channel is the one that closes it.
- Receivers only range over it; they never close it.
- Return receive-only channels (
<-chan T) so callers can't send or close by mistake.
func generate(ctx context.Context, n int) <-chan int {
out := make(chan int)
go func() {
defer close(out) // the owner closes
for i := range n {
select {
case out <- i:
case <-ctx.Done():
return
}
}
}()
return out
}Collapse duplicate work with singleflight
When a cache entry expires under load, a thousand requests can miss at once and all hit the backend. golang.org/x/sync/singleflight lets one caller do the work while the rest wait for its result. I use it for things like reloading signing keys when an unknown key ID shows up:
var group singleflight.Group
func (v *Verifier) refreshAndGet(kid string) (any, error) {
_, err, _ := group.Do("jwks", func() (any, error) {
return nil, v.reloadJWKS() // runs once, however many requests arrive together
})
if err != nil {
return nil, err
}
if key, ok := v.keys.Load(kid); ok {
return key, nil
}
return nil, fmt.Errorf("unknown key id %q", kid)
}Choose the right shared-state tool
- `sync.Mutex` / `sync.RWMutex` for a struct with several fields that must change together.
- `sync.Map` for a cache that is written rarely and read constantly from many goroutines — like a key set.
- `atomic.Int64` / `atomic.Pointer[T]` for counters and for swapping a whole immutable config at once.
- Channels when the point is to hand work or ownership to another goroutine, not to protect a variable.
Make the race detector part of CI
go test -race ./... catches data races that never show up on a laptop and always show up in production. It slows tests down, so run it in CI on every pull request rather than on every local save.
A short checklist
- Every goroutine has a context or a done channel.
- Every fan-out has a limit.
- Every channel has exactly one owner that closes it.
- Expensive reloads go through
singleflight. -raceruns in CI.
None of this is exotic. It is just the discipline that keeps a fast Go service fast under real traffic.