Advanced Concurrency Patterns

The worker pool pattern in full, context.Context for cancellation and timeouts, and collecting errors from concurrent work with errgroup.

The worker pool pattern, in full

Spawning one goroutine per unit of work is fine for a few dozen or a few thousand tasks, but it stops being fine once the number of tasks is large or unbounded — millions of goroutines each doing a small amount of work, or each holding open a connection to a rate-limited downstream service, can exhaust memory or overwhelm that downstream service outright. A worker pool bounds concurrency deliberately: a fixed number of goroutines all pull from a shared jobs channel, so no more than N units of work are ever in flight at once, no matter how many jobs arrive.

Go
package main

import (
	"fmt"
	"sync"
)

func worker(jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
	defer wg.Done()
	for j := range jobs { // exits automatically once jobs is closed and drained
		results <- j * j // pretend this is meaningfully expensive work
	}
}

func main() {
	const numWorkers = 4
	jobs := make(chan int, 100)
	results := make(chan int, 100)

	var wg sync.WaitGroup
	for w := 0; w < numWorkers; w++ {
		wg.Add(1)
		go worker(jobs, results, &wg)
	}

	for j := 1; j <= 20; j++ {
		jobs <- j
	}
	close(jobs) // no more jobs will be sent — workers finish in-flight work, then exit their range loop

	go func() {
		wg.Wait()      // block until every worker has drained `jobs` and returned
		close(results) // only now is it safe to close — nothing will send on it again
	}()

	total := 0
	for r := range results {
		total += r
	}
	fmt.Println("total:", total) // 2870
}

Two details make this pattern actually safe rather than just "goroutines and channels":

  • close(jobs) happens after every job has been sent, and only ever from the sending side — closing a channel a receiver might still write to is a bug, not just a style preference.
  • close(results) happens in its own goroutine, gated behind wg.Wait(), specifically so it runs only after every worker is guaranteed to be done sending. Closing results directly in main before that would either panic (a worker still trying to send on a closed channel) or, if placed after the for r := range results loop, would deadlock (nothing would ever close it, so the loop would block forever).

Reach for a worker pool whenever the number of tasks is large or unknown up front, or whenever tasks call something with real capacity limits — a downstream API, a database, a rate-limited third-party service — where unbounded concurrency would be actively harmful rather than merely wasteful.

Canceling and timing out work with context.Context

A goroutine that's blocked waiting on something slow (a network call, a database query) has no way to know the caller gave up — unless something tells it to stop. context.Context is the standard mechanism for exactly that: it carries a cancellation signal (and an optional deadline) across API boundaries and down through however many goroutines a request fans out into.

Go
package main

import (
	"context"
	"fmt"
	"time"
)

func slowOperation(ctx context.Context, result chan<- string) {
	select {
	case <-time.After(3 * time.Second): // stands in for genuinely slow work
		result <- "operation finished"
	case <-ctx.Done():
		return // the caller already gave up — stop doing unnecessary work
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
	defer cancel() // always call cancel, even on the path where the context "wins" naturally

	result := make(chan string, 1)
	go slowOperation(ctx, result)

	select {
	case res := <-result:
		fmt.Println(res)
	case <-ctx.Done():
		fmt.Println("timed out:", ctx.Err()) // context deadline exceeded
	}
}
Plaintext
timed out: context deadline exceeded

context.Background() is the root of every context tree — an empty context with no deadline and nothing canceled, meant to be passed only at the very top of a program (in main, or the start of a request handler). WithTimeout and WithDeadline derive a child context that cancels itself automatically once the time is up; WithCancel derives one you cancel manually by calling the returned function. Cancellation flows downward: canceling a parent cancels every context derived from it, which is what lets one HTTP request's cancellation (a client disconnecting) automatically stop every goroutine, database call, and downstream request that request had fanned out into.

ctx.Err() explains why a context is done — context.Canceled if something called cancel() explicitly, or context.DeadlineExceeded if a deadline/timeout elapsed on its own. Idiomatic Go passes ctx context.Context as a function's first parameter by convention, and calls defer cancel() immediately after creating a cancelable context — even when the operation is expected to finish long before the deadline, since skipping it leaks the timer goroutine backing that context until the parent context itself is canceled or garbage collected.

context.WithValue exists too, for carrying request-scoped metadata (a request ID, an auth token) across API boundaries that don't otherwise have a place to put it — but it's easy to overuse. It bypasses the compiler's type checking entirely (values are stored and retrieved by an untyped key, as any), so it should stay reserved for genuinely cross-cutting concerns, not as a substitute for passing an ordinary function parameter.

Collecting errors from concurrent work

sync.WaitGroup (from the goroutines-channels page) tells you when a group of goroutines has finished — it has no built-in way to tell you whether any of them failed. Doing that manually means adding a mutex-protected slice (or a dedicated error channel) alongside the WaitGroup, just to collect errors safely from multiple goroutines:

Go
// The manual way — works, but this bookkeeping has to be re-written for every
// place that needs "run N things concurrently, tell me if any failed."
var (
	mu   sync.Mutex
	errs []error
	wg   sync.WaitGroup
)

for i := 0; i < 5; i++ {
	i := i
	wg.Add(1)
	go func() {
		defer wg.Done()
		if err := fetch(i); err != nil {
			mu.Lock()
			errs = append(errs, err)
			mu.Unlock()
		}
	}()
}
wg.Wait()

golang.org/x/sync/errgroup packages exactly this pattern, and adds automatic cancellation on top: errgroup.WithContext returns a derived context that's canceled the instant any goroutine in the group returns a non-nil error, so sibling goroutines that check ctx.Done() can stop early instead of finishing work nobody needs anymore.

Go
package main

import (
	"context"
	"fmt"

	"golang.org/x/sync/errgroup"
)

func fetch(ctx context.Context, id int) (int, error) {
	if id == 3 {
		return 0, fmt.Errorf("item %d: not found", id)
	}
	select {
	case <-ctx.Done():
		return 0, ctx.Err()
	default:
		return id * 10, nil
	}
}

func main() {
	g, ctx := errgroup.WithContext(context.Background())
	results := make([]int, 5)

	for i := 0; i < 5; i++ {
		i := i
		g.Go(func() error {
			val, err := fetch(ctx, i)
			if err != nil {
				return err
			}
			results[i] = val
			return nil
		})
	}

	if err := g.Wait(); err != nil {
		fmt.Println("failed:", err)
		return
	}
	fmt.Println(results)
}
Plaintext
failed: item 3: not found

g.Wait() blocks until every goroutine launched with g.Go() has returned, then returns the first non-nil error encountered — not a combined list of all of them. That's the right default for "stop and report the first failure," but if a caller genuinely needs every error from every goroutine (not just the first), errgroup isn't the tool for that — go back to a manually-collected slice, or a dedicated error-aggregation library.

Bash
go get golang.org/x/sync/errgroup

Comparison: coordination primitives

Primitive Coordinates Signals Built-in error handling
sync.WaitGroup "wait for N goroutines to finish" none none — build it yourself
channel + select passing data, or a simple done signal data, or a closed channel none — build it yourself
context.Context cancellation and deadlines across a call tree Done() channel, Err() only via Err(), not a general error value
errgroup.Group WaitGroup + cancellation on first failure first error from Wait() built-in

Common mistakes

  • Spawning one goroutine per task with no upper bound when the number of tasks is large or unbounded — exhausts memory, or overwhelms whatever downstream resource each task touches. A worker pool caps this deliberately.
  • Forgetting defer cancel() after context.WithTimeout/WithCancel — leaks the context's internal timer goroutine until the parent context is itself canceled or garbage collected, even when the operation succeeds well before the deadline.
  • Passing context.Background() deep into a call chain instead of threading the caller's ctx through — cancellation silently stops propagating past that point, and nothing above it can time out or cancel that branch of work anymore.
  • Using context.WithValue as a general parameter-passing mechanism instead of reserving it for cross-cutting, request-scoped metadata — it trades compile-time type safety for convenience, which only pays off for things like request IDs and auth tokens.
  • Assuming errgroup's Wait() reports every failure — it only returns the first error; the other goroutines' errors are discarded unless the caller collects them separately.

Interview questions

Q: What problem does a worker pool solve that spawning a goroutine per task doesn't? It bounds concurrency to a fixed number of workers regardless of how many tasks arrive, preventing unbounded goroutine growth from exhausting memory or overwhelming a downstream resource (a rate-limited API, a database) that the tasks call into.

Q: Why call the cancel function returned by context.WithTimeout even when the operation completes successfully well before the timeout? Every cancelable context (from WithTimeout, WithDeadline, or WithCancel) holds internal resources — for a timeout, a running timer goroutine — that stay alive until either the deadline fires or cancel() is called explicitly. Skipping defer cancel() on the success path leaks that timer for as long as the parent context lives.

Q: How does errgroup improve on a plain sync.WaitGroup for concurrent work that might fail? sync.WaitGroup only tracks completion, not success or failure — collecting errors from multiple goroutines manually requires a mutex-protected slice or a dedicated channel. errgroup.Group handles that collection internally, returns the first error from Wait(), and (via errgroup.WithContext) automatically cancels a shared context the moment any goroutine fails, so unfinished sibling goroutines can stop early instead of completing wasted work.