All Articles
Engineering
9 min read
January 20, 2025

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.

GoConcurrencyBackendPerformance

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.