AlgoMaster Logo

Concurrency Best Practices

High Priority18 min readUpdated June 6, 2026

This lesson is the cheat sheet for everything in the concurrency-patterns section. Each rule is one short statement, a brief reason, a before/after example, and a pointer to the topic that owns the underlying mechanism. Treat it as a checklist you can run against any concurrent Go code you're about to write or review.

Choose Your Tool

"Don't communicate by sharing memory; share memory by communicating" is the Go proverb, and it's a good default. Channels make ownership flow through the program, which makes the data race impossible by construction. But it's a default, not a law. For a single counter, a cache, or a config map, a sync.Mutex or a value in sync/atomic is shorter and faster than a goroutine sitting behind a channel. Use channels when goroutines coordinate (passing work, signalling, ordering), and a mutex or atomic when goroutines share state (counters, caches, lookup tables).

NeedBest primitive
Pass work between goroutinesChannel
Fan out work to N workersWorker pool over a channel
Process data in stagesPipeline over channels
Protect a shared counteratomic.Int64
Protect a shared map or structsync.Mutex
Many readers, few writerssync.RWMutex
Run a function exactly oncesync.Once
Reuse expensive allocationssync.Pool
Fan out with error propagationerrgroup.Group
Wait for N goroutines to finishsync.WaitGroup
Cancel a tree of workcontext.Context
Broadcast "stop" to many goroutinesDone channel via close

A Healthy Goroutine Lifecycle

Every goroutine you launch should follow the same lifecycle. It starts, it has a clear path to cancellation, and it exits when that path fires. If you can't draw this picture for a goroutine before you launch it, you don't have a goroutine, you have a leak in waiting.

At launch, confirm the goroutine has some exit signal: a context, a done channel, or an input channel whose close it will detect. During work it watches that signal. When the signal fires, deferred cleanups release locks, decrement WaitGroups, and close output channels. Then the goroutine returns and the runtime reclaims its stack. The next ten rules are all variations of "keep this picture true".

Rule 1: Share Memory by Communicating, Except When You Shouldn't

Statement: Prefer channels for goroutine coordination. Use mutex or atomic when you're actually sharing a small piece of state.

Why: Channels make ownership visible. When a value travels through a channel, it's clear which goroutine owns it at each moment, and the race detector has nothing to complain about. But channels add scheduling overhead. For a single counter or a small map, a mutex protects the same data with less code and lower latency.

Before (channel for a counter, overkill):

After (atomic for a counter, idiomatic):

Output: orders placed: 5

The atomic version is shorter, has no permanent coordinator goroutine, and is faster under contention. The channel version isn't wrong, it's just wrong for this shape of problem.

Rule 2: Bound the Work

Statement: Never go func() inside an unbounded for loop. Use a worker pool or errgroup.SetLimit.

Why: A request that fans out one goroutine per cart item is fine when carts have 10 items. The same code with a bulk-import endpoint that accepts 50,000 items can spawn 50,000 goroutines, each making an HTTP call to a downstream service that has 100 connections. The downstream service falls over, the goroutines pile up waiting on TCP, and the process runs out of memory.

Before (unbounded fan-out):

After (bounded with `errgroup.SetLimit`):

Output: enriched 1000 items

The work still gets done, but at most 16 calls run at once. The downstream service stays healthy and memory stays flat.

Rule 3: Every Goroutine Needs an Exit Path

Statement: Before launching a goroutine, answer the question "how does this exit?" If the answer is "it doesn't", you have a leak.

Why: Goroutines that never exit hold their stack memory and any variables they reference, forever. A leak of one goroutine per request, on a service that handles a few hundred requests per second, fills up a small VM in hours. The three valid exit paths are a closed done channel, a cancelled context, or the input channel they're reading from being closed.

Before (no exit path, classic leak):

After (context-cancellable):

Output: goroutines: 1

Rule 4: Pass Context, Don't Store It

Statement: Accept context.Context as the first parameter of any function that does I/O, waits, or starts goroutines. Never store it on a struct.

Why: Contexts model the lifetime of a single operation. Storing one on a struct conflates "the context for the current request" with "the context for the struct's whole life", which makes cancellation impossible to reason about. The standard library and the Go style guide are both explicit: pass it explicitly through every call.

Before (context on the struct):

After (context per call):

Output: order 101 processed

Now each Process call has its own deadline. The struct doesn't care.

Rule 5: Don't Hold a Lock Across I/O or a Channel Send

Statement: Lock, mutate, unlock. Don't lock, mutate, then make a network call.

Why: A lock held during a slow call blocks every other goroutine that wants the lock, for the entire duration of the call. One slow downstream service becomes a stall for every request that touches the same mutex. The same applies to a channel send while holding a lock: if the receiver isn't ready, you're holding a global lock while parked.

Before (lock held across I/O):

After (release lock before I/O):

Output: price: $9.99

There's a small race: two callers may both miss the cache and both call the API. For most caches that's fine. If duplicate calls are unacceptable, singleflight.Group from golang.org/x/sync/singleflight deduplicates concurrent in-flight requests.

Rule 6: Don't Share the Loop Variable Into a Goroutine Closure

Statement: When launching a goroutine inside a for loop on Go 1.21 or earlier, copy the loop variable. On Go 1.22+, the language fixed this, but the rule still applies to any other captured variable.

Why: Before Go 1.22, the loop variable in for i, v := range items was a single variable reused across iterations. A goroutine closure that captured v saw whichever value v held when the goroutine actually ran, which was often the last value, because the loop ran faster than the scheduler. Go 1.22 changed for loop semantics so each iteration gets a fresh variable. Older code, code targeting older Go versions, and closures over non-loop variables still need explicit shadowing.

Before (broken on Go 1.21 and earlier):

After (safe on every Go version):

The other safe pattern is passing the value as an argument: go func(p string) { ... }(p). The argument is evaluated when go runs, so each goroutine gets its own copy.

Rule 7: Use errgroup for Fan-Out With Errors

Statement: For "do these N independent things in parallel and fail fast if any of them errors", use errgroup.Group.

Why: sync.WaitGroup gets you the "wait for N goroutines" half, but it doesn't capture errors and doesn't cancel siblings when one fails. Hand-rolling error fan-in with a buffered error channel works, but it's the kind of code that develops subtle bugs over time. errgroup is the standard library's answer: it captures the first error, cancels the group's context, and Wait returns the error to the caller.

Before (WaitGroup with manual error fan-in):

After (errgroup):

Output: error: product 2: unavailable

The errgroup version also cancels ctx the moment product 2 fails, so the other two goroutines can stop early. The plain WaitGroup version doesn't, every sibling has to run to completion before the error is visible.

Rule 8: Run -race in CI

Statement: Every test run in CI should use go test -race ./.... Treat any race report as a build failure.

Why: Data races in Go are undefined behavior. The race detector catches them when they happen during a test run, with the stack trace of both racing goroutines. The earlier in the development cycle a race is caught, the cheaper it is to fix. Catching one three weeks before a customer hits it is the goal.

Before: go test ./...

After: go test -race -count=1 ./...

The -count=1 disables the test cache; the race detector only finds races on actual executions, so a cached pass won't catch a race introduced in the code under test. The race detector won't catch every race (it only sees races that actually happen during the test), but it will catch the ones your tests exercise, which is usually plenty.

Rule 9: Profile Before Optimising

Statement: When a service has latency spikes, dump the mutex profile with go tool pprof before changing any code. Benchmark before and after.

Why: Contention is usually invisible from logs. A mutex held by goroutine A blocks goroutines B through Z, but the symptom shows up as "request latency p99 doubled". The mutex profile points straight at the lock. Without it, you're guessing, and the most-suspected locks are often not the actual problem.

Before (guessing at the bottleneck):

After (profile, then act):

Read it with go tool pprof mutex.pprof and (pprof) top or (pprof) list Cache.Get. The output points at Cache.Get because every read serializes through the same mutex. The fix is sync.RWMutex so concurrent reads don't block each other, but you only know that's the right fix because the profile said so.

Rule 10: Keep Goroutine Bodies Small and Pure

Statement: A goroutine should do one thing. Push coordination to the edges. Pull pure computation into the middle.

Why: Goroutines that mix work with coordination (lock, send, receive, lock, send) are hard to test and hard to reason about. A goroutine that receives input from a channel, calls a pure function, and sends output to another channel is the easiest shape to verify. The pure function can be unit tested without any concurrency. The channels can be tested with a fake producer and a fake consumer.

Before (one big goroutine doing everything):

After (pure function, coordination separated):

Output: [108 216]

The pure finalTotal has zero concurrency concerns and is trivial to unit test. The goroutine body is one line. Writing to totals[i] is race-free because each goroutine writes a unique index.

Putting It Together: An Order Enrichment Service

The example below takes a batch of orders, enriches each one with pricing and inventory data from downstream services, and reports how many succeeded. It applies most of the rules together: bounded concurrency, context-driven cancellation, a mutex-protected counter held only for the increment, and small goroutine bodies that delegate to plain methods.

Run this with go test -race and the race detector stays quiet. Every write to results goes to a unique index. Every read or write of succeeded is guarded by s.mu. Every downstream call sits outside the lock. The errgroup's gctx is derived from the caller's ctx, so the 2-second timeout propagates to every in-flight call. Swap one item for "broken" and the first failure cancels gctx, every in-flight enrichOne returns on its next select, and g.Wait() returns the error. The composition matters more than any single rule: the pool bounds memory, the context bounds time, the mutex bounds the critical section, and the small worker body bounds the surface area to test.

Quiz

Concurrency Best Practices Quiz

10 quizzes