Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 063 / 100

Concurrency patterns in Go: patterns, pitfalls and fixes

Patterns you will use in real services.

Expona Admin17 min read

Introduction

Goroutines make concurrent programming in Go look deceptively simple—spin up thousands of lightweight threads, send data down channels, and let the runtime handle the rest. But simple syntax masks complexity: without proper concurrency patterns in Go: patterns, pitfalls and fixes, you'll ship race conditions, deadlocks, and resource leaks to production. We've debugged all three at scale across trading platforms and ride-hailing services. The patterns we cover here—fan-out/fan-in, pipelines, bounded parallelism, and cancellation—form the foundation of every production API we've built.

Fan-out, fan-in: Spreading work and merging results

Fan-out is the pattern of distributing a single input across multiple goroutines that process work in parallel. Fan-in is collecting those results back into a single channel. Together, they let you parallelize CPU or I/O bound tasks without manually managing a goroutine pool. The key insight is that fan-out decouples the producer from the workers—the producer sends work to a channel, and any number of workers can consume from it.

Real-world scenario: You have 10,000 order IDs and need to fetch their status from an external API. Instead of sequential requests, spawn a goroutine per batch and merge responses. In a ride-hailing system, this translates to fetching driver locations, vehicle status, and rating data in parallel for a single ride request. Without fan-out, three sequential API calls could each take 200ms; with fan-out, they complete in ~200ms total.

go
func fetchOrderStatuses(ctx context.Context, orderIDs []string) ([]Order, error) {
	// Fan-out: spawn goroutine per order
	resultChan := make(chan Order, len(orderIDs))
	errChan := make(chan error, len(orderIDs))
	var wg sync.WaitGroup

	for _, id := range orderIDs {
		wg.Add(1)
		go func(orderID string) {
			defer wg.Done()
			order, err := fetchOrder(ctx, orderID)
			if err != nil {
				errChan <- err
				return
			}
			resultChan <- order
		}(id)
	}

	// Wait for all goroutines to complete
	go func() {
		wg.Wait()
		close(resultChan)
		close(errChan)
	}()

	// Fan-in: collect results
	var orders []Order
	for order := range resultChan {
		orders = append(orders, order)
	}

	// Check for any errors
	for err := range errChan {
		if err != nil {
			return nil, err
		}
	}

	return orders, nil
}

The pattern is clean but fragile: if fetchOrder panics or hangs indefinitely, you have no timeout. Always wrap this with context cancellation and a timeout. The errChan buffer size should match the number of goroutines; if it's too small and multiple goroutines error simultaneously, they'll block trying to send errors.

Edge case: What if you want to fail fast on the first error instead of collecting all results? Restructure to use ctx.Done():

go
func fetchOrderStatusesFailFast(ctx context.Context, orderIDs []string) ([]Order, error) {
	resultChan := make(chan Order)
	var wg sync.WaitGroup
	var mu sync.Mutex
	var orders []Order

	for _, id := range orderIDs {
		wg.Add(1)
		go func(orderID string) {
			defer wg.Done()
			order, err := fetchOrder(ctx, orderID)
			if err != nil {
				return // Context will propagate, other goroutines will see Done()
			}
			select {
			case <-ctx.Done():
				return
			case resultChan <- order:
			}
		}(id)
	}

	go func() {
		wg.Wait()
		close(resultChan)
	}()

	for {
		select {
		case <-ctx.Done():
			return nil, ctx.Err()
		case order, ok := <-resultChan:
			if !ok {
				return orders, nil
			}
			mu.Lock()
			orders = append(orders, order)
			mu.Unlock()
		}
	}
}

Here, if any goroutine encounters an error or ctx.Done() fires, no goroutine sends to resultChan, and the loop eventually closes and returns. This pattern prevents wasted work after failure.

Pipelines: Chaining stages through channels

Pipelines chain processing stages where each stage reads from an input channel, transforms data, and writes to an output channel. This separates concerns, makes testing easier, and scales naturally. Each stage is a black box: it doesn't need to know about upstream or downstream stages. This makes debugging and replacing stages trivial—you can swap the implementation of one stage without touching others.

Real-world scenario: Build a three-stage payment processing pipeline: validate transaction → charge card → update ledger. Each stage runs as its own set of goroutines. In a high-throughput payment system, you might have 10 validators, 50 chargers (limited by payment processor concurrency), and 20 ledger writers. If a stage backs up, the previous stage's buffer fills, naturally throttling input.

go
type Transaction struct {
	ID     string
	Amount float64
	Card   string
}

// Stage 1: Validate
func validateStage(ctx context.Context, in <-chan Transaction) <-chan Transaction {
	out := make(chan Transaction)
	go func() {
		defer close(out)
		for {
			select {
			case <-ctx.Done():
				return
			case tx, ok := <-in:
				if !ok {
					return
				}
				if tx.Amount > 0 && len(tx.Card) > 0 {
					out <- tx
				}
			}
		}
	}()
	return out
}

// Stage 2: Charge
func chargeStage(ctx context.Context, in <-chan Transaction) <-chan Transaction {
	out := make(chan Transaction)
	go func() {
		defer close(out)
		for {
			select {
			case <-ctx.Done():
				return
			case tx, ok := <-in:
				if !ok {
					return
				}
				// Call payment processor
				if err := processCharge(ctx, tx); err == nil {
					out <- tx
				}
			}
		}
	}()
	return out
}

// Stage 3: Record
func recordStage(ctx context.Context, in <-chan Transaction) <-chan Transaction {
	out := make(chan Transaction)
	go func() {
		defer close(out)
		for {
			select {
			case <-ctx.Done():
				return
			case tx, ok := <-in:
				if !ok {
					return
				}
				// Update ledger
				updateLedger(ctx, tx)
				out <- tx
			}
		}
	}()
	return out
}

// Connect stages
func processPipeline(ctx context.Context, txs []Transaction) <-chan Transaction {
	in := make(chan Transaction)
	go func() {
		defer close(in)
		for _, tx := range txs {
			in <- tx
		}
	}()

	validated := validateStage(ctx, in)
	charged := chargeStage(ctx, validated)
	recorded := recordStage(ctx, charged)

	return recorded
}

Pipelines excel at latency-sensitive workloads because partial results flow through immediately. The downside: understanding data flow across many goroutines requires careful tracing. Always include tracing or logging at stage boundaries.

Common mistake: Creating multiple goroutines per stage in a single stage function:

go
// WRONG: Creates N goroutines for the same stage
func chargeStageMulti(ctx context.Context, in <-chan Transaction, workers int) <-chan Transaction {
	out := make(chan Transaction)
	var wg sync.WaitGroup

	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for tx := range in {
				if err := processCharge(ctx, tx); err == nil {
					out <- tx
				}
			}
		}()
	}

	go func() {
		wg.Wait()
		close(out)
	}()

	return out
}

This works but couples stage logic to concurrency. A cleaner approach wraps the single-stage function with a fan-out multiplier if more parallelism is needed. Alternatively, use errgroup.WithContext and SetLimit within a stage to control concurrent work internally without exposing it.

Edge case—backpressure: If stage 2 is slower than stage 1, the channel between them fills up. If buffered with 1000 transactions, stage 1 slows down trying to send the 1001st. This is desired behavior—it prevents memory exhaustion. But if you need to detect when a stage is lagging, monitor channel buffer depth.

Bounded parallelism: Limiting concurrent work

Unbounded goroutines exhaust memory and database connections. Bounded parallelism restricts concurrent work to a fixed limit—either via a semaphore pattern or Go 1.22's errgroup.WithContext with SetLimit. In production systems, this is non-negotiable: a database allows only so many concurrent connections, an external API rate-limits requests, or your infrastructure can handle only N concurrent operations before degrading.

Real-world scenario: Process 100,000 CSV rows but limit database inserts to 50 concurrent writes. Without a limit, spawning 100,000 goroutines immediately exhausts memory (each goroutine costs ~2KB in stack), and database connection pool exhaustion causes cascading failures.

go
import "golang.org/x/sync/errgroup"

func processCsvRows(ctx context.Context, rows []map[string]string) error {
	eg, ctx := errgroup.WithContext(ctx)
	eg.SetLimit(50) // Max 50 concurrent inserts

	for _, row := range rows {
		row := row // Capture for closure
		eg.Go(func() error {
			return insertRow(ctx, row)
		})
	}

	return eg.Wait()
}

The errgroup pattern is modern and clean: if any goroutine returns an error, eg.Wait() returns it immediately and cancels remaining work via the context. Compare to manual semaphore management:

go
func processCsvRowsManual(ctx context.Context, rows []map[string]string) error {
	sem := make(chan struct{}, 50)
	errChan := make(chan error, len(rows))
	var wg sync.WaitGroup

	for _, row := range rows {
		wg.Add(1)
		go func(r map[string]string) {
			defer wg.Done()
			sem <- struct{}{}        // Acquire
			defer func() { <-sem }() // Release

			if err := insertRow(ctx, r); err != nil {
				errChan <- err
			}
		}(row)
	}

	go func() {
		wg.Wait()
		close(errChan)
	}()

	for err := range errChan {
		if err != nil {
			return err
		}
	}
	return nil
}

Both work, but errgroup.SetLimit is terser and handles context propagation correctly. The manual semaphore approach requires explicit error channel management and is prone to missing errors if the loop exits early. Use errgroup unless you have special cleanup needs.

Edge case—dynamic limit adjustment: What if you want to adjust the limit at runtime based on system load? Semaphores don't support this easily; instead, use a worker pool pattern with dynamic resizing or implement adaptive concurrency using metrics. For example, if database latency exceeds a threshold, reduce concurrency:

go
type AdaptiveProcessor struct {
	limit   int
	mu      sync.Mutex
	latency time.Duration
}

func (ap *AdaptiveProcessor) processWithAdaptation(ctx context.Context, rows []map[string]string) error {
	eg, ctx := errgroup.WithContext(ctx)

	for _, row := range rows {
		ap.mu.Lock()
		currentLimit := ap.limit
		ap.mu.Unlock()

		eg.SetLimit(currentLimit)

		row := row
		eg.Go(func() error {
			start := time.Now()
			err := insertRow(ctx, row)
			latency := time.Since(start)

			ap.mu.Lock()
			ap.latency = latency
			if latency > 100*time.Millisecond && ap.limit > 10 {
				ap.limit-- // Reduce concurrency
			} else if latency < 50*time.Millisecond && ap.limit < 100 {
				ap.limit++ // Increase concurrency
			}
			ap.mu.Unlock()

			return err
		})
	}

	return eg.Wait()
}

This is advanced and should be reserved for truly dynamic scenarios. Most of the time, a fixed limit sized to your infrastructure constraints is best.

Cancellation: Respecting context in every pattern

A goroutine that ignores ctx.Done() is a goroutine leak. Every pattern must check the context signal—whether it's a timeout, explicit cancellation, or a parent goroutine failing. Context cancellation is how the runtime tells a goroutine "stop what you're doing"—ignoring it wastes CPU, memory, and database connections.

The pattern is always the same: wrap your work in a select statement. This is idiomatic Go and appears in every well-written concurrent program.

go
func workerWithCancellation(ctx context.Context, job string) error {
	select {
	case <-ctx.Done():
		return ctx.Err() // Context cancelled or timed out
	default:
	}

	// Do work, checking context periodically
	for i := 0; i < 100; i++ {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
			// Process iteration
			time.Sleep(10 * time.Millisecond)
		}
	}
	return nil
}

The first check is important even before the loop: if the context is already canceled, fail fast. The loop check ensures responsiveness—a goroutine that waits for a 10-second operation to complete before checking context again will block cancellation for 10 seconds.

Real-world scenario: An API request times out after 5 seconds. Every goroutine spawned to handle that request must exit cleanly, closing database connections and releasing resources. If goroutines ignore the timeout, the server accumulates zombie goroutines, eventually running out of file descriptors and memory.

go
func handleRequest(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	result, err := fetchDataWithLimit(ctx, 1000)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(result)
}

func fetchDataWithLimit(ctx context.Context, limit int) (interface{}, error) {
	eg, ctx := errgroup.WithContext(ctx)
	eg.SetLimit(limit)

	var results []interface{}
	mu := &sync.Mutex{}

	for i := 0; i < 10000; i++ {
		i := i
		eg.Go(func() error {
			select {
			case <-ctx.Done():
				return ctx.Err() // Abort this goroutine
			default:
			}

			data, err := expensiveQuery(ctx, i)
			if err != nil {
				return err
			}

			mu.Lock()
			results = append(results, data)
			mu.Unlock()
			return nil
		})
	}

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

Without ctx.Done() checks, goroutines keep running even after the client disconnects, wasting resources. The HTTP server doesn't cancel the request context automatically—you must check it explicitly. When context.WithTimeout fires, ctx.Done() returns a closed channel; all waiting goroutines wake up, and if they check the context, they can exit cleanly.

Common pitfall: Forgetting to pass context to external library calls:

go
// WRONG: ignores context
data, err := expensiveQuery(ctx, i)

// But inside expensiveQuery:
func expensiveQuery(ctx context.Context, id int) (interface{}, error) {
	// Never uses ctx!
	return database.Query("SELECT ... WHERE id = ?", id) // No context passed
}

// CORRECT:
func expensiveQuery(ctx context.Context, id int) (interface{}, error) {
	return database.QueryContext(ctx, "SELECT ... WHERE id = ?", id)
}

Many database drivers, HTTP clients, and API libraries accept ctx as an argument. If you don't pass it, timeouts and cancellations don't propagate. Always use context-aware variants of library functions.

Handling panics in goroutines

A panic in a goroutine crashes only that goroutine, not the program. This is both a blessing and a curse: your program survives a single goroutine's panic, but you lose that work. Production systems must recover from panics gracefully.

go
func safeWorker(ctx context.Context, job string) {
	defer func() {
		if r := recover(); r != nil {
			log.Printf("Goroutine panicked: %v", r)
			// Log stack trace for debugging
		}
	}()

	// Do risky work
	riskyOperation()
}

// Or wrap in a helper for repeated use
func withRecover(fn func() error) func() error {
	return func() error {
		defer func() {
			if r := recover(); r != nil {
				log.Printf("Recovered from panic: %v", r)
			}
		}()
		return fn()
	}
}

// Usage
eg.Go(withRecover(func() error {
	return riskyOperation()
}))

Always log the panic and its stack trace; silent recovery hides bugs. In fan-out patterns, wrap each goroutine with recovery to ensure one failure doesn't cascade.

Common mistakes

  • Ignoring context cancellation: Goroutines that don't check ctx.Done() leak resources. Always include context checks, especially in tight loops. If a goroutine runs for more than a few milliseconds without checking context, it's too long.
  • Buffering channels carelessly: A buffered channel hides synchronization issues. Buffer size should match expected producer/consumer imbalance, not be arbitrary. A buffer of 1000 for an unbounded fan-out is a memory leak waiting to happen.
  • Closing channels from the wrong side: Only the sender should close a channel. If multiple senders exist, use sync.WaitGroup to coordinate a single closer goroutine. Sending on a closed channel causes a panic.
  • Panics in goroutines: A panic in a spawned goroutine crashes only that goroutine, not the program. Use recover() or wrap risky operations in a helper that logs panics and returns errors instead.
  • Unguarded shared state: Fan-out patterns accumulate results into a slice; protect with sync.Mutex or use a channel-based collector instead. Data races are silent and corrupt state unpredictably.
  • No timeout on fan-out: If one goroutine hangs indefinitely, wg.Wait() blocks forever. Always pass a context with a timeout to wg.Wait() indirectly via cancellation checks in workers.
  • Assuming order of results: Fan-in collects results from any goroutine; if order matters, tag each result with its original index or use a slice with pre-allocated indices.
  • Missing error channel buffer: If the error channel size doesn't match the number of goroutines and multiple fail, early goroutines block trying to send errors. This cascades into deadlocks.

Race condition debugging

Always run tests with the -race flag to detect data races before production:

go
// Run tests with race detection
// go test -race ./...

// Example of a race the detector catches:
var count int

go func() {
	count++ // Write
}()

go func() {
	_ = count // Read
}()
// RACE DETECTED: concurrent map writes

Use sync.Mutex, sync.RWMutex, or channels to protect shared state. If the race detector reports a false positive (it rarely does), use sync.Atomic for simple counters:

go
var count atomic.Int64

go func() {
	count.Add(1)
}()

go func() {
	_ = count.Load()
}()

Key takeaways

  • Use fan-out/fan-in to parallelize independent work; protect shared result collection with mutexes or channels. Always buffer error channels to match goroutine count.
  • Pipelines decompose multi-stage processing; each stage is independent and testable, but data flow complexity grows with depth. Keep stages simple and single-purpose.
  • Bounded parallelism via errgroup.SetLimit prevents resource exhaustion; prefer it over manual semaphores for most cases. Size the limit to your infrastructure constraints.
  • Context cancellation is non-negotiable: every goroutine must check ctx.Done() in loops and before long operations. Pass context to all library calls that accept it.
  • Panic recovery in goroutines prevents silent failures. Always recover, log, and continue gracefully.
  • Test concurrency patterns with -race flag and load-test under realistic conditions—bugs surface at scale, not in unit tests. Use pprof to detect goroutine leaks and memory growth over time.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles