All articles

Go Concurrency in Practice: Goroutines, Channels, and Context

How to use goroutines without leaking them, when to pick a channel or a mutex, how context carries cancellation through a request, worker pools with errgroup, and the race detector habits that keep concurrent Go code correct.

Backend Development|Published |10 min read
Code on two monitors seen through a pair of glasses

Starting a goroutine takes two letters: go. That is why concurrency is one of the first things people enjoy about Go, and also why it is behind many of the production bugs we see in Go services. Goroutines that never exit, maps written from two places at once, and requests that keep running after the client has gone. The patterns below are the ones we teach and use to avoid those problems.

Every Goroutine Needs a Way to Stop

A goroutine is cheap, but it is never collected while it is blocked. A goroutine waiting forever on a channel nobody will write to holds its stack and everything it references until the process restarts. Before writing go func(), answer one question: what makes this goroutine return? The answer is usually a closed channel, a cancelled context, or the end of a finite piece of work. If there is no answer, it is a leak.

Channel or Mutex?

SituationUseWhy
Passing work or results from one goroutine to anotherChannelOwnership of the data moves with the value, so only one goroutine touches it at a time
Signalling that something is finished or should stopClosed channel or contextClosing wakes every receiver at once
Several goroutines reading and updating a shared map or countersync.Mutex or sync.RWMutexSimpler and faster than routing every update through a goroutine
A single number updated from many goroutinessync/atomic types such as atomic.Int64No lock needed for one value

The Go proverb says to share memory by communicating, and channels are a good default for pipelines. Still, a mutex around a small struct is often the clearest code. Pick whichever makes ownership obvious to the next reader.

Pass Context Through Every Call

In an HTTP server, r.Context() is cancelled when the client disconnects or the server shuts down. Pass it as the first argument to every function that does I/O, and use the context-aware versions of database and HTTP calls such as QueryContext and http.NewRequestWithContext. Then a cancelled request stops its database queries and outbound calls as well, instead of finishing work nobody will read.

  • Set timeouts with context.WithTimeout around calls to other services, and always call the cancel function with defer.
  • Inside long loops, check ctx.Done() in a select so the loop can exit early.
  • Do not store a context in a struct. Pass it per call.
  • Use context.WithoutCancel for work that must finish after the response is sent, such as writing an audit log.

Wait for Goroutines With errgroup

sync.WaitGroup waits for goroutines, and since Go 1.25 its Go method starts and tracks them in one call. When the goroutines can fail, golang.org/x/sync/errgroup is usually the better tool. errgroup.WithContext returns a group and a context. If any goroutine returns an error, the context is cancelled so the others can stop, and Wait returns the first error.

A typical use is loading a dashboard that needs data from three services. Start three g.Go calls, each writing into its own variable, then call g.Wait. The page waits only as long as the slowest call, and one failure cancels the remaining two.

Limit Concurrency With a Worker Pool

Starting one goroutine per item works for ten items and falls over at a hundred thousand, usually by exhausting database connections or hitting another API's rate limit. Bound the number of workers.

  1. 1Create the group with errgroup.WithContext and call g.SetLimit(n), where n matches what the downstream system can take.
  2. 2Loop over the items and call g.Go for each one. Go blocks when n goroutines are already running, which applies backpressure for free.
  3. 3Inside each goroutine, return early if ctx.Err() is not nil.
  4. 4Collect results in a slice indexed by position, or send them on a channel read by one goroutine.
  5. 5Call g.Wait and handle the error once.

Use Select for Timeouts and Cancellation

A select statement waits on several channel operations and runs whichever is ready first. Combine a result channel with ctx.Done() so a goroutine can give up when the caller stops waiting. When a goroutine sends its result on a channel that the caller might stop reading, give the channel a buffer of one so the send never blocks and the goroutine can exit.

Find Data Races Before Production Does

  • Run tests with go test -race in CI. The race detector only finds races in code that actually runs, so it works best with tests that exercise concurrent paths.
  • Plain maps are not safe for concurrent writes. Go will crash the program with a concurrent map writes error. Protect them with a mutex.
  • Close a channel only from the sending side, and only once. Sending on a closed channel panics.
  • Recover from panics inside long-lived goroutines you start yourself. A panic in any goroutine stops the whole program.
  • Watch the goroutine count in your metrics. A number that keeps rising after traffic drops points to a leak, and the goroutine profile from net/http/pprof shows where they are stuck.

One Bug That Is Gone

Before Go 1.22, a for loop reused one variable for every iteration, so goroutines started inside the loop often all saw the last value. Since Go 1.22 each iteration gets its own variable when the module declares go 1.22 or later in go.mod. Older code still contains the item := item workaround, which is now safe to remove after upgrading.

Concurrent code is easy to write in Go. Making it stop cleanly is where the real work is.

Our Golang training spends a full day on these patterns with exercises that leak, race, and deadlock on purpose, so engineers learn to recognise the symptoms before they meet them in production.

Key takeaways

  • Know what makes each goroutine return before you start it, or it will leak.
  • Use channels to hand off data and signals, and a mutex or atomic for shared state.
  • Pass context as the first argument to every I/O call so cancellation reaches the database and other services.
  • Use errgroup with SetLimit to wait for goroutines, cancel on the first error, and cap concurrency.
  • Run go test -race in CI and monitor the goroutine count in production.

Related articles

More articles on software development, AI, cloud, and infrastructure.

Hands sorting printed tax forms and receipts next to a calculator
AI Development|

Automating Document Data Entry With AI: Invoices, Receipts, and Forms

How AI reads invoices, receipts, ID cards, and forms and turns them into structured data, how OCR and large language models fit together, why validation and human review still matter, how to measure accuracy, and what to watch for with personal data.

Aerial view of a busy container port with cranes and stacked shipping containers
DevOps|

Deploying Applications to Kubernetes With Helm: A Practical Guide

What Helm charts are, how values and templates work, how to structure a chart for several environments, upgrade and rollback safely, keep secrets out of Git, test charts in CI, and decide between Helm and Kustomize.

Looking for a software development partner?

Tell us about your project, what you need to build, and the challenges you are facing. We can discuss the technical approach, scope, timeline, and estimated cost.

Start a conversation