Back to Blogs
June 28, 20264 min read

Go Concurrency in Practice

Goroutines and channels are elegant, but real-world concurrency needs more than that. Here's how I think about concurrent Go in production services.

Why Concurrency Is Hard

Concurrency bugs are invisible until they cause real damage. A race condition in a payment service might surface once every hundred thousand requests — impossible to reproduce locally, catastrophic when it does.

Go's concurrency model is genuinely good, but "good" doesn't mean "automatic." You still need to think carefully about ownership, lifetimes, and cancellation.

Goroutines Are Cheap — Until They're Not

The Go runtime can schedule hundreds of thousands of goroutines. This creates a subtle trap: it's tempting to spawn goroutines freely without tracking them.

// This leaks goroutines if the channel is never drained
func processBatch(items []Item) {
    ch := make(chan Result)
    for _, item := range items {
        go func(i Item) {
            ch <- process(i) // blocks forever if nobody reads
        }(item)
    }
}

Always ensure goroutines can exit. If a goroutine sends to a channel, someone must read it, or you must provide a way to abandon the send.

Contexts for Cancellation

Every goroutine that does I/O should respect context.Context. This is not optional in production code.

func fetchUser(ctx context.Context, id string) (*User, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet,
        fmt.Sprintf("/users/%s", id), nil)
    if err != nil {
        return nil, err
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("fetch user: %w", err)
    }
    defer resp.Body.Close()

    var user User
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        return nil, fmt.Errorf("decode user: %w", err)
    }
    return &user, nil
}

When the caller cancels the context (timeout, request abort), the HTTP client cancels the in-flight request. Without this, your goroutines keep running after the caller has moved on.

WaitGroups for Fan-Out

When you fan out to N goroutines and need to wait for all of them, sync.WaitGroup is the tool.

func enrichOrders(ctx context.Context, orders []Order) ([]EnrichedOrder, error) {
    results := make([]EnrichedOrder, len(orders))
    errs := make([]error, len(orders))

    var wg sync.WaitGroup
    for i, order := range orders {
        wg.Add(1)
        go func(idx int, o Order) {
            defer wg.Done()
            enriched, err := enrich(ctx, o)
            results[idx] = enriched
            errs[idx] = err
        }(i, order)
    }
    wg.Wait()

    // Collect first non-nil error
    for _, err := range errs {
        if err != nil {
            return nil, err
        }
    }
    return results, nil
}

Note the index assignment results[idx] — writing to separate indices in a slice from multiple goroutines is safe because they don't overlap. This is not the same as concurrent writes to a map.

errgroup for Cleaner Fan-Out

golang.org/x/sync/errgroup combines WaitGroup with first-error propagation:

func enrichOrdersErrGroup(ctx context.Context, orders []Order) ([]EnrichedOrder, error) {
    g, ctx := errgroup.WithContext(ctx)
    results := make([]EnrichedOrder, len(orders))

    for i, order := range orders {
        i, order := i, order // capture loop vars
        g.Go(func() error {
            enriched, err := enrich(ctx, order)
            if err != nil {
                return fmt.Errorf("order %s: %w", order.ID, err)
            }
            results[i] = enriched
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return results, nil
}

When errgroup.WithContext creates a context, it cancels that context when any goroutine returns an error. All other goroutines should check ctx.Done() and exit early.

Mutexes vs Channels

Go's famous advice: "communicate by sharing memory, don't share memory by communicating." But in practice, a mutex is often simpler.

Use a channel when:

  • You're passing ownership of data between goroutines
  • You want to signal events or completion
  • You're implementing a worker pool or pipeline

Use a mutex when:

  • Multiple goroutines need read/write access to shared state
  • The protected section is short
  • You'd otherwise build a channel just to serialize access
type Cache struct {
    mu    sync.RWMutex
    items map[string]Item
}

func (c *Cache) Get(key string) (Item, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    item, ok := c.items[key]
    return item, ok
}

func (c *Cache) Set(key string, item Item) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = item
}

The sync.RWMutex allows concurrent reads and exclusive writes — a good default for a read-heavy cache.

The One Rule

If you're not sure which primitive to use, ask: who owns this data, and when does ownership transfer? Channels make ownership transfer explicit. Mutexes keep shared ownership but serialize access. Clarity about ownership prevents most concurrency bugs before they're written.

Written by

Zikri Akmal Santoso

Software Engineer

More Articles