Context, cancellation and concurrency patterns

context.Context and deadlines, errgroup, worker pools, fan-in and fan-out, select with defaults, sync primitives, and the race detector.

Context and deadlines

func fetchAll(ctx context.Context, urls []string) ([]string, error) {
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()          // always cancel, even on the success path

    results := make([]string, 0, len(urls))
    for _, u := range urls {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
        if err != nil {
            return nil, err
        }
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            if errors.Is(err, context.DeadlineExceeded) {
                return nil, fmt.Errorf("timed out fetching %s: %w", u, err)
            }
            return nil, err
        }
        resp.Body.Close()
        results = append(results, u)
    }
    return results, nil
}

// request-scoped values: keys are unexported types, never strings
type ctxKey struct{}

func withRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, ctxKey{}, id)
}
  • ctx is the first parameter by convention, and it is never stored in a struct or passed as nil — use context.Background() or context.TODO() at the top.
  • Cancellation is advisory: it closes ctx.Done(), and it is the function's job to notice. A loop that never checks the context runs to completion regardless.
  • Always call the cancel function, even when the work succeeded, so the timer and the parent-child link are released.
  • WithValue is for request metadata such as a trace id or an auth principal, not for optional parameters. If the value is required for the function to work, it belongs in the signature.
  • Cancelling the parent cancels every derived context; cancelling a child never affects the parent.

Worker pools, fan-out and errgroup

// errgroup: a WaitGroup that stops the group on the first error
func process(ctx context.Context, jobs []Job) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(8)                     // bounded concurrency: backpressure included

    for _, j := range jobs {
        j := j                        // not needed since Go 1.22, kept for clarity
        g.Go(func() error {
            return handle(ctx, j)     // returning an error cancels the group
        })
    }
    return g.Wait()                   // the first non-nil error
}

// fan-out to N workers, fan-in to one channel
func squares(ctx context.Context, in <-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    for w := 0; w < 4; w++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for {
                select {
                case n, ok := <-in:
                    if !ok {
                        return
                    }
                    select {
                    case out <- n * n:
                    case <-ctx.Done():
                        return
                    }
                case <-ctx.Done():
                    return
                }
            }
        }()
    }
    go func() { wg.Wait(); close(out) }()   // exactly one closer
    return out
}
ToolReach for it when
sync.WaitGroupYou just need to wait for a fixed set of goroutines, with no error and no limit
errgroup.GroupThe tasks can fail, and one failure should stop the rest — the usual case for parallel I/O
errgroup.SetLimit(n)You need a worker pool without writing one; it caps goroutines and blocks the producer
semaphore.WeightedThe limit is a resource such as bandwidth or memory, not a task count
sync.Mutex / RWMutexSmall amounts of genuinely shared mutable state, such as a cache map
atomicA counter or a flag, where a lock would be heavier than the operation
  • Unbuffered channels give you a handshake and natural backpressure; buffered channels give you throughput at the cost of memory and less obvious blocking.
  • Close a channel from the sender side, and only after every sender is done — a sync.Once or a dedicated waiter goroutine is the standard idiom.
  • A select with default is a non-blocking poll, useful for draining or for "try to hand this off, otherwise keep the item and retry later".

Shared state and the race detector

type Cache struct {
    mu sync.RWMutex
    m  map[string][]byte
}

func (c *Cache) Get(k string) ([]byte, bool) {
    c.mu.RLock()                      // many readers at once
    defer c.mu.RUnlock()
    v, ok := c.m[k]
    return v, ok
}

func (c *Cache) Set(k string, v []byte) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.m[k] = v
}

// sync.Once for lazy initialisation, safe under concurrency
var (
    once   sync.Once
    client *http.Client
)

func httpClient() *http.Client {
    once.Do(func() {
        client = &http.Client{Timeout: 10 * time.Second}
    })
    return client
}

Run every test twice before believing a concurrent design: once normally and once with go test -race -count=1 ./.... The detector reports both conflicting accesses with their stacks, which converts a rare corrupted value into a reproducible bug report.

⚠️
A data race is undefined behaviour, not a race you might win. Reading a map while another goroutine writes it can crash the process outright, and a race on a plain field can produce a value no goroutine ever wrote. If the detector reports it, fix it — do not add a sleep and hope.

FAQ

Why does errgroup cancel my other tasks?
errgroup.WithContext derives a context that is cancelled as soon as any goroutine returns an error, and Wait returns the first error. If the tasks are independent, use a plain WaitGroup and collect errors yourself.
Mutex or channel?
Use a mutex to protect state that many goroutines read and write, such as a cache. Use channels when ownership of data passes from one goroutine to another, or when you need to wait, time out or select. Channels are built on locks, so neither is faster in the abstract.

Building HTTP services Errors, defer and panic

Last refreshed 2026-09-18.