Go Concurrency Patterns I Actually Use in Production
Beyond the basics — worker pools, fan-out/fan-in, semaphore-bounded goroutines, and the context cancellation patterns that keep distributed systems from turning into memory leak generators.
Go’s concurrency model is genuinely beautiful — until you have a goroutine leak in production and spend two hours staring at a flat memory profile trying to find it. These are the patterns I’ve standardized on after building several high-throughput services.
Worker Pool with Bounded Concurrency
The naive version: spin up a goroutine for every item. The production version: control how many run simultaneously.
func processItems(ctx context.Context, items []Item, concurrency int) error {
sem := make(chan struct{}, concurrency)
errs := make(chan error, len(items))
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
sem <- struct{}{} // acquire slot
go func(i Item) {
defer wg.Done()
defer func() { <-sem }() // release slot
if err := process(ctx, i); err != nil {
errs <- err
}
}(item)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
return err // return first error (or collect all)
}
}
return nil
}
The semaphore channel (sem) is the key. It limits concurrent goroutines to concurrency without needing a third-party library.
Fan-Out / Fan-In
Distribute work across N workers, collect results into one channel.
func fanOut[T, R any](
ctx context.Context,
input <-chan T,
workers int,
fn func(context.Context, T) (R, error),
) <-chan result[R] {
out := make(chan result[R])
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for item := range input {
select {
case <-ctx.Done():
return
default:
val, err := fn(ctx, item)
out <- result[R]{val: val, err: err}
}
}
}()
}
go func() {
wg.Wait()
close(out)
}()
return out
}
Note the ctx.Done() check inside the worker. This is non-negotiable in production — without it, goroutines will keep running after the context is cancelled, leaking resources.
Context Propagation: The Right Way
Every function that does I/O should accept a context.Context as its first argument. No exceptions. This gives you:
- Request-scoped cancellation
- Timeout propagation
- Distributed trace IDs (via middleware)
// Wrong — no way to cancel this
func fetchUserData(userID string) (*User, error) {
return db.Query("SELECT * FROM users WHERE id = $1", userID)
}
// Right — context-aware
func fetchUserData(ctx context.Context, userID string) (*User, error) {
return db.QueryContext(ctx, "SELECT * FROM users WHERE id = $1", userID)
}
Timeout Patterns
Three ways to add timeouts, in order of preference:
// 1. Context with timeout (preferred — composes with cancellation)
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// 2. Select with time.After (one-off, no context needed)
select {
case result := <-ch:
return result, nil
case <-time.After(5 * time.Second):
return nil, errors.New("timed out")
}
// 3. time.AfterFunc for cleanup (fire-and-forget with deadline)
timer := time.AfterFunc(5*time.Second, func() {
conn.Close()
})
defer timer.Stop()
Always defer cancel() immediately after context.WithTimeout. If you forget, you leak the timer goroutine until the parent context is cancelled.
Detecting Goroutine Leaks
In tests, use goleak:
func TestMyService(t *testing.T) {
defer goleak.VerifyNone(t)
// ... your test
}
In production, monitor runtime.NumGoroutine() as a metric. A steadily growing goroutine count is almost always a leak.
The single most impactful change you can make to a Go codebase: audit every goroutine launch site and ask “what stops this goroutine from running forever?” If the answer isn’t immediate, you have a latent leak.
Found this useful? I write about AI engineering, distributed systems, and cloud infrastructure.