Available · full-time / contract · UK hoursRésumé ↓
← All posts
Go

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.
  • -race runs in CI.

None of this is exotic. It is just the discipline that keeps a fast Go service fast under real traffic.