Go's concurrency primitives are deceptively simple. go func() to start a goroutine, channels to communicate, select to coordinate. After a week, every Go developer thinks they understand it. After a year, they have seen most of the ways it can go wrong: leaks, deadlocks, race conditions that only appear under load.
This post is the patterns that work in production Go code, the failure modes that are easy to hit, and the modern tooling (errgroup, context propagation, sync/x) that has emerged since Go 1.18.
Goroutine Leaks Are the Default
Starting a goroutine is so easy that it is easy to forget that it has to end. A goroutine that blocks on a channel receive forever is a leak — the runtime cannot clean it up until the channel is closed or the goroutine exits.
// Leak: if the receiver does not consume, the sender blocks forever
go func() {
result := computeExpensiveThing()
resultChan <- result // blocks if no one is reading
}()
The fix is to make every blocking operation cancellable. The idiomatic way in modern Go is context.
go func() {
result := computeExpensiveThing()
select {
case resultChan <- result:
case <-ctx.Done():
return
}
}()
Every goroutine should either complete predictably or accept a cancellation signal. No goroutine should be "fire and forget" in production code.
Context Propagation
context.Context is how cancellation, deadlines, and request-scoped values flow through Go programs. Every function that might block on I/O takes a context as its first parameter.
func handleRequest(ctx context.Context, req Request) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
data, err := fetchData(ctx, req.ID)
if err != nil {
return err
}
return saveData(ctx, data)
}
The contract: respect the context's cancellation. Long loops should check ctx.Done(). I/O calls should pass the context through.
Functions that take a context and ignore it are bugs in waiting. Lint for it.
errgroup Over Raw goroutines
golang.org/x/sync/errgroup is what WaitGroup should have been. It tracks errors, cancels siblings on the first error, and waits for all goroutines to finish.
import "golang.org/x/sync/errgroup"
func fetchAll(ctx context.Context, urls []string) ([]Result, error) {
g, ctx := errgroup.WithContext(ctx)
results := make([]Result, len(urls))
for i, url := range urls {
i, url := i, url // capture loop variables
g.Go(func() error {
r, err := fetch(ctx, url)
if err != nil {
return err
}
results[i] = r
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
In Go 1.22+, the loop variable capture is no longer needed — but for older code, the i, url := i, url pattern is the classic guard.
errgroup.WithContext returns a derived context that is cancelled when the first goroutine returns an error. Other goroutines see the cancellation and stop.
Channel Patterns That Work
Worker pool. Multiple consumers reading from one channel.
jobs := make(chan Job, 100)
for i := 0; i < 10; i++ {
go func() {
for job := range jobs {
process(job)
}
}()
}
for _, job := range allJobs {
jobs <- job
}
close(jobs) // tells workers to stop
Buffered channels smooth out producer-consumer rate differences. The producer closes the channel when there is no more work; the for range loops exit.
Fan-out, fan-in. One producer, multiple workers, one collector.
jobs := make(chan Job)
results := make(chan Result)
// Workers
for i := 0; i < 10; i++ {
go func() {
for job := range jobs {
results <- process(job)
}
}()
}
// Collector
go func() {
// ... wait for all workers then close results
}()
The tricky part is closing results after all workers are done. Use an errgroup to track them and close the channel after Wait returns.
Mutex Versus Channels
The Go community has spent two decades arguing about this. The pragmatic answer: use a mutex when you are protecting shared state, use a channel when you are coordinating goroutines.
// Mutex: protecting state
type Cache struct {
mu sync.Mutex
m map[string]string
}
func (c *Cache) Set(k, v string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = v
}
// Channel: coordinating
done := make(chan struct{})
go func() {
defer close(done)
longTask()
}()
<-done // wait for completion
The "share memory by communicating" slogan is a useful default, but a sync.Mutex around a map is simpler than threading the map through channels.
Race Detector
Run tests with go test -race. The race detector catches concurrent reads/writes to shared memory that lack synchronization. The output is precise enough to find the exact lines involved.
go test -race ./...
Run it in CI. Production code that ships with data races will eventually corrupt data — usually during a traffic spike when the race window opens.
Common Anti-Patterns
Forgetting to close channels. A for range loop on a channel that never closes is an infinite block.
Closing channels from the receiver side. Channels should be closed by the sender, never the receiver. Closing from a receiver causes panics when the sender tries to write.
Channels of channels of channels. Sometimes necessary, often a sign that the design is wrong. Look for simpler structures first.
Naked goroutines without context. Any goroutine that might block on I/O without a context-driven exit path is a leak waiting to happen.
Sharing slices between goroutines without sync. Slices look like values but their underlying arrays are shared. A goroutine that appends to a slice while another reads it is a data race.
sync.Once and sync.Map
Two stdlib utilities that show up often:
var once sync.Once
var instance *Service
func GetService() *Service {
once.Do(func() {
instance = NewService()
})
return instance
}
sync.Once is the right way to do lazy initialization across goroutines. Hand-rolled implementations almost always have races.
sync.Map is concurrent-safe map. Use it when read and write rates from many goroutines are roughly equal. For most uses, a map with a sync.Mutex is faster — sync.Map is optimized for specific access patterns.
Backpressure
Unbounded channels accept work faster than consumers process it. Eventually, memory growth becomes a problem.
// Bounded buffer = backpressure
jobs := make(chan Job, 100)
// Producer blocks if buffer is full
jobs <- job
When the buffer fills, producers block. That backpressure propagates up the system — the upstream slows down rather than queueing unbounded work.
For HTTP servers, this often means using a semaphore or worker pool to bound concurrency rather than spawning a goroutine per request unboundedly.
When Concurrency Is Wrong
Not every program needs goroutines. A sequential implementation is often faster than a concurrent one for:
- Small workloads (overhead of goroutine creation dominates)
- Workloads with serial dependencies (each step needs the previous step's result)
- Workloads bounded by a single resource (database connection, single CPU core)
Profile before parallelizing. Concurrent Go can be slower than sequential Go on the same workload if the work is small enough.
The Production Pattern
A robust pattern for a concurrent task in production:
func processAll(ctx context.Context, items []Item) error {
g, ctx := errgroup.WithContext(ctx)
sem := make(chan struct{}, 10) // concurrency limit
for _, item := range items {
sem <- struct{}{}
item := item
g.Go(func() error {
defer func() { <-sem }()
return processWithContext(ctx, item)
})
}
return g.Wait()
}
errgroupfor error handling and cancellation- Semaphore for concurrency limit
- Context for deadlines
- Defer for cleanup
This pattern handles errors correctly, bounds resource use, respects cancellation, and cleans up. It is more code than go func() but does not leak or surprise.
Reviewing Go code that has acquired concurrency bugs nobody can reproduce locally? We help teams audit goroutine lifecycles, context propagation, and the patterns that fail under real production load. scopeforged.com