arrow_backBACK TO GO FOR BACKEND ENGINEERS — CONCURRENCY, SIMPLICITY AND REAL NUMBERS
Lesson 06Go for Backend Engineers — Concurrency, Simplicity and Real Numbers6 min read

Concurrency Patterns

October 29, 2026

TL;DR

A few patterns cover most real concurrency. WORKER POOL: start N workers that pull from one jobs channel and push to one results channel — because goroutines are cheap (Lesson 4) but the WORK often isn't (a DB conn, a rate-limited API, memory), so you cap concurrency at N. FAN-OUT/FAN-IN: many workers read one channel (fan-out), their outputs merge into one (fan-in). PIPELINE: stages connected by channels, each stage a goroutine. WAIT: sync.WaitGroup (Add/Done/Wait) to wait for a batch to finish; errgroup to also collect the first error. And the big one — context.Context: the FIRST argument of almost every I/O function, threading a cancellation signal and deadline through the whole call tree of a request. When the client disconnects or a 2s timeout fires, ctx.Done() closes and every goroutine and DB/HTTP call carrying that ctx bails out — no orphaned work. Pass ctx down, never store it in a struct; always use the Context variants (QueryContext etc.).

Goroutines (Lesson 4) and channels (Lesson 5) are the primitives. In practice you combine them into a small number of recurring patterns — and if you learn these four or five, you can build almost any concurrent backend logic. This lesson is those patterns, ending with the one that every production Go service is built around: context for cancellation and deadlines.

The Worker Pool

The most common pattern. You have many jobs to do and you could spawn a goroutine per job — but the goroutines are not the constraint; the work they do is. Each job might open a database connection, call an API with a rate limit, or use a chunk of memory. Spawn a million goroutines each grabbing a DB connection and you will exhaust the database instantly (Lesson 10). So you bound the concurrency: start a fixed number of workers, N, and feed them from a shared channel.

Concurrency pattern: worker pool. A jobs channel, chan Job, into which a producer sends tasks. N workers (worker 1, worker 2, worker 3) each pull from the jobs channel; N equals GOMAXPROCS or a tuned limit. Each worker pushes outputs into a results channel, chan Result, which a consumer drains. Why a pool and not one goroutine per job here: goroutines are cheap (Lesson 4) but the WORK they do often is not — each may open a DB connection, call an API with a rate limit, or use memory; spawning a goroutine per job for a million jobs would overwhelm those downstream resources. A worker pool caps concurrency at N: start N workers, feed them from one jobs channel, collect from one results channel, and close jobs when done; you get parallelism with a ceiling you control. Related shapes: fan-out (many workers off one channel), fan-in (merge many channels into one), and pipeline (stages connected by channels, each stage a goroutine).

The shape in code:

jobs := make(chan Job, 100)
results := make(chan Result, 100)

// start N workers
for i := 0; i < N; i++ {
    go func() {
        for job := range jobs {        // each worker pulls until jobs is closed
            results <- process(job)
        }
    }()
}

// feed jobs, then close so workers' range loops end
for _, j := range allJobs {
    jobs <- j
}
close(jobs)

N is the concurrency ceiling you control — often GOMAXPROCS for CPU-bound work, or a number tuned to what the downstream resource (DB pool size, API rate limit) can take. All workers share one jobs channel, so each pulls the next available job with no coordination code from you — the channel distributes the work. You get parallelism with a limit, which is exactly what you want when the work touches finite resources.

Related shapes are variations on this:

  • Fan-out: many worker goroutines reading from one channel (that’s the pool above — one source, many consumers).
  • Fan-in: the reverse — merge the outputs of many goroutines into one channel for a single consumer to process.
  • Pipeline: a chain of stages, each a goroutine, connected by channels — stage 1 reads input and sends to stage 2’s channel, stage 2 transforms and sends to stage 3, and so on. Each stage runs concurrently, like a factory line, and channels provide the backpressure between stages automatically.

Waiting for Goroutines to Finish

A goroutine runs in the background; main or your handler often needs to wait until a batch of them is done. Two tools:

  • sync.WaitGroup — a counter. wg.Add(n) before starting n goroutines, wg.Done() (usually deferred) inside each as it finishes, and wg.Wait() blocks until the counter hits zero. This is the standard “launch a batch, wait for all of them” primitive:
var wg sync.WaitGroup
for _, item := range items {
    wg.Add(1)
    go func(it Item) {
        defer wg.Done()
        handle(it)
    }(it)
}
wg.Wait()   // returns once every goroutine has called Done
  • golang.org/x/sync/errgroup — like a WaitGroup, but it also collects the first error and can cancel the rest via a context. When you fan out work and want “run these concurrently, wait for all, and fail fast if any errors,” errgroup is the clean choice.

(Note the go func(it Item){...}(it) above passes it as an argument. In older Go, a loop variable captured by a closure was shared across iterations — a classic bug. Go 1.22 changed loop variables to be per-iteration, fixing it, but passing explicitly is still clear and safe.)

context: Cancellation and Deadlines

Now the pattern that every Go backend is built around. A server handles a request by fanning out work — a DB query, a cache lookup, two API calls — often across several goroutines. What happens when the client disconnects, or the request exceeds its time budget? You want all that work to stop, immediately, so you are not burning CPU and holding connections for a response nobody will read. That coordination is what context.Context provides.

context: cancellation and deadlines. An HTTP request creates ctx, cancel colon-equals WithTimeout 2 seconds. That ctx fans out to several children: a DB query, a cache call, an API call — each with ctx passed in. A ctx.Done() box closes when: the timeout fires, the client hangs up, or cancel() is called; and every goroutine watching it stops. The rule every Go backend lives by: context.Context is the FIRST argument of almost every function that does I/O, func Get(ctx, id); it threads a cancellation signal and a deadline through the whole call tree of a request. When the client disconnects or the 2s timeout fires, ctx.Done() closes and every goroutine and DB or HTTP call carrying that ctx bails out — no orphaned work burning CPU for a response nobody wants. Pass it down, never store it in a struct; check ctx.Err() in loops; pass it to db.QueryContext etc. This is how a Go service sheds load gracefully under pressure instead of piling up zombie work.

A context.Context carries two things through a call tree: a cancellation signal and an optional deadline. The conventions:

  • It is the first argument of almost every function that does I/O or significant work: func (s *Service) GetOrder(ctx context.Context, id int) (Order, error). You thread it down through every layer.
  • You derive child contexts. The HTTP server gives you a request context (r.Context()); you wrap it with a timeout: ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second); defer cancel(). Now everything below has a 2-second budget.
  • ctx.Done() is a channel that closes when the context is cancelled — because the deadline passed, the client disconnected, or someone called cancel(). Every goroutine and every I/O call that holds the context watches ctx.Done() (often via select, Lesson 5) and bails out when it closes.

The effect is that one cancellation fans out: the timeout fires, ctx.Done() closes, and the DB query (via QueryContext), the HTTP calls (via http.NewRequestWithContext), and your own loops (checking ctx.Err()) all stop together. No orphaned goroutines grinding away on work whose answer is already unwanted. This is how a Go service sheds load gracefully under pressure — slow or abandoned requests free their resources instead of piling up into a death spiral.

Two rules keep context usage clean:

  • Pass it down the call stack; never store it in a struct. Context is per-request and flows with the call. Stashing one in a long-lived struct field defeats its purpose and causes subtle bugs.
  • Always use the ...Context variants of library calls — db.QueryContext, db.ExecContext, http.NewRequestWithContext — so cancellation actually reaches the I/O. A plain db.Query ignores the context and will keep running after the client has gone.

context ties together everything from these concurrency lessons: goroutines doing the work, channels (ctx.Done()) signalling cancellation, and a clean way to bound and stop a request’s entire fan-out. It is not optional knowledge — it is in the signature of virtually every method in a real Go backend, and the HTTP and database lessons (8 and 10) use it throughout.

Pitfalls to Avoid

  1. One goroutine per job against a finite resource. Spawning unbounded goroutines that each grab a DB connection or hit a rate-limited API overwhelms the resource. Use a worker pool to cap concurrency at what the downstream can take.
  2. Forgetting to close the jobs channel. Workers rangeing over jobs block forever if it’s never closed — the pool hangs and wg.Wait() never returns. Close the channel once all jobs are sent.
  3. WaitGroup misuse. Calling wg.Add inside the goroutine (racey — Wait may run first), or forgetting defer wg.Done() on an error path (counter never reaches zero, Wait hangs). Add before go, defer Done first thing inside.
  4. Not calling cancel(). context.WithTimeout/WithCancel return a cancel func you must call (usually defer cancel()) — skipping it leaks the context’s resources (a timer, a goroutine) until the deadline. Always defer cancel().
  5. Ignoring the context. Taking a ctx but calling db.Query instead of db.QueryContext, or never checking ctx.Err() in a long loop. Then cancellation does nothing and work runs on after the client left. Thread the context into every I/O call.
  6. Storing context in a struct, or passing nil. Context is meant to flow as an argument per request. Don’t cache it in a field; don’t pass nil (use context.Background() at the top, context.TODO() as a temporary placeholder).

Quick Recap

  • Worker pool: N workers pull from one jobs channel and push to one results channel — bound concurrency at N because the work (DB conns, rate limits, memory), not the goroutines, is the constraint. Fan-out/fan-in and pipelines are variations.
  • Wait for goroutines with sync.WaitGroup (Add/Done/Wait); use errgroup when you also want the first error and fail-fast cancellation.
  • context.Context is the pattern every Go backend lives by: the first argument of nearly every I/O function, threading a cancellation signal + deadline through the whole request. When the client leaves or a timeout fires, ctx.Done() closes and all the fanned-out work stops together — graceful load-shedding, no orphaned work.
  • Pass context down, never store it; always use the ...Context call variants; always defer cancel().

Next lesson: when you do share memory, how to do it safely — mutex vs atomic vs channel, with measurements, and the race detector that catches your mistakes.