Five Fetches and the Bugs You Can Write by Hand
A service enriches each incoming request by fetching five upstream APIs in parallel. The rules: fail the whole request on the first upstream failure, and stop the remaining fetches as soon as failure is certain — waiting for the other four after one has 503'd wastes seconds the client will not forgive.
The hand-rolled version needs five moving parts, and each is a place to be subtly wrong:
var (
wg sync.WaitGroup
mu sync.Mutex
first error
)
ctx, cancel := context.WithCancel(parent)
defer cancel()
for _, u := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
if err := fetch(ctx, u); err != nil {
mu.Lock()
if first == nil {
first = err
cancel() // tell the siblings to stop
}
mu.Unlock()
}
}(u)
}
wg.Wait()
if first != nil {
return first
}
Now audit what could have gone wrong instead: storing first without the mutex (data race, caught only under -race if at all); calling cancel() unconditionally so the last error wins instead of the first; forgetting cancel() entirely so the derived context outlives the request and leaks its timer; incrementing the WaitGroup after the goroutine starts (rare early return → Wait fires early). Four independent correctness hazards, every one of them invisible in review unless you know to look.
golang.org/x/sync/errgroup collapses the whole arrangement into its standard shape:
import "golang.org/x/sync/errgroup"
func fetchAll(urls []string) error {
g, ctx := errgroup.WithContext(context.Background())
for _, u := range urls {
g.Go(func() error {
return fetch(ctx, u) // siblings stop when any fetch fails
})
}
return g.Wait() // first error, or nil if all succeeded
}
The signature states the contract: every g.Go function returns an error; g.Wait returns the first non-nil one; the derived ctx is cancelled the moment any function fails. The mutex, the error bookkeeping, and the cancel-call discipline are gone because the group owns them. What errgroup does not hide is ordering: WithContext must be called before the g.Go calls, the ctx passed into the closures must be the derived one (using the parent defeats sibling cancellation), and g.Wait must be called exactly once — those three rules cover nearly every errgroup bug in the wild.
What Exactly Cancels, and Which Error Survives
errgroup's value is its precise semantics, and interviews probe them directly. Three sentences define the whole contract:
- The first non-nil error cancels the derived context. Siblings that check
ctx(viahttp.NewRequestWithContext,selectonctx.Done(), or a context-aware driver) stop promptly; siblings that ignore the context keep running until they finish naturally — cancellation is cooperative, exactly as in Cancellation with context. g.Waitreturns that first error. Errors returned after the first are recorded nowhere — first-failure-wins is a deliberate policy, not an oversight. If you need every failure, collect them yourself (theerrors.Joinpattern from Error Handling) or carry errors on a results channel instead.- The derived context is also cancelled when
Waitreturns — even on success. This is the gotcha that bites in production: afterg.Wait, the ctx you handed to children is dead.
Rule 3 has teeth with HTTP:
bodies := make([]string, len(urls)) // NOT []*http.Response
g, ctx := errgroup.WithContext(context.Background())
for i, u := range urls {
i, u := i, u // loop-var capture explicit for pre-1.22 Go
g.Go(func() error {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("fetch %s: %w", u, err)
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body) // read INSIDE the goroutine
if err != nil {
return fmt.Errorf("read %s: %w", u, err)
}
bodies[i] = string(b)
return nil
})
}
if err := g.Wait(); err != nil {
return err
}
Two structures worth naming. Results go into an indexed slice, not an append. Each goroutine writes only bodies[i] — its own slot — and distinct elements of a slice are distinct memory, so no mutex is needed; a shared append would race exactly like the hand-rolled code's first field. Read the body inside the goroutine. Returning *http.Response looks useful but the body read happens after Wait cancelled the request context, and reads on a cancelled request fail or truncate. Compute the durable value (bytes, parsed struct) before returning; keep lifetimes inside the group.
The per-goroutine discipline this reduces to: take ctx from the group, do context-aware work, write only your own index, return a wrapped error with %w, and never touch a sibling's slot. Five rules, no locks.
Bounding the Storm, and When errgroup Is Wrong
Launching 5,000 goroutines through g.Go recreates the unbounded-spawn problem from Worker Pools — errgroup changes failure semantics, not physics. SetLimit turns the group into a bounded pool:
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(16) // at most 16 goroutines live at once
for _, id := range itemIDs {
id := id
g.Go(func() error {
return process(ctx, id) // g.Go BLOCKS here while 16 are running
})
}
if err := g.Wait(); err != nil {
return err
}
With a limit set, g.Go blocks until a slot frees — the calling goroutine becomes the dispatcher, and memory stays proportional to the limit rather than the task count. TryGo is the non-blocking variant: it returns false instead of waiting, for callers that must react to saturation (requeueing, shedding) rather than stall. Set the limit before the first g.Go, and size it by workload class exactly as a pool is sized: GOMAXPROCS for CPU work, the dependency's concurrency budget for I/O.
Knowing when not to use errgroup is the advanced half of the topic. The honest decision table:
| Situation | Tool |
|---|---|
| Fixed set of independent tasks, first failure should cancel the rest | errgroup (this topic) |
| Long-lived workers streaming from a channel, no shared cancel | plain WaitGroup + results channel (fan-in) |
| Data flows through stages and must keep flowing despite individual item failures | pipeline with error-carrying result structs |
| Need every failure, not the first | errgroup with per-index error slots, or errors.Join after Wait |
| No parallelism — sequential order matters | a plain for loop; errgroup adds nothing but goroutines |
The distinction in the first two rows is the one interviewers listen for: errgroup models a batch of work with an outcome — N calls, one result, abort-on-failure — while a pipeline models a stream with ongoing throughput, where one bad item should be reported without necessarily stopping every item after it. The cancellation plumbing errgroup gives away for free is precisely the layer the hand-rolled fan-out pattern from the previous topic lacked; taking it does not change the topology, only who is responsible for the error and when the siblings hear about it.
Quiz
1. Three goroutines run under `g, ctx := errgroup.WithContext(parent)`. The second returns an error while the third is mid-work and checks ctx. What happens?
2. Workers under errgroup all `append` results to one shared slice. What is wrong, and what is the fix?
3. After `g.Wait()` returns nil, goroutines still hold the ctx derived from `errgroup.WithContext`. What is true?
4. You must run 50,000 independent HTTP calls with first-failure cancellation, using at most 32 concurrent calls. Which structure fits?
Flashcards
Question
What three things does `g, ctx := errgroup.WithContext(parent)` set up?
Click to reveal answer
Answer
A Group you launch work on with g.Go, a derived context cancelled on the first non-nil error (and again when Wait returns), and the pairing that makes Wait return exactly that first error. Children must receive the derived ctx — passing parent silently disables sibling cancellation.
Question
Why is the slice-write pattern `results[i] = v` safe under errgroup while a shared `append` is not?
Click to reveal answer
Answer
Each goroutine writes exclusively to its own element, and distinct slice elements are distinct memory locations, so no two writes touch the same address — no mutex needed. Concurrent appends do race, because append reads and writes the shared length and may reallocate the backing array. Pre-size the slice before launching.
Question
Your errgroup HTTP fetches fail after Wait with context.Canceled even though every upstream returned 200. What is happening?
Click to reveal answer
Answer
You are reading response bodies after g.Wait — but Wait cancelled the derived context, and the request context governs body reads. Read (or fully buffer) each body inside its g.Go function before returning nil, so the durable result exists before Wait's cancellation lands.
Question
What does g.SetLimit(16) change about a loop calling g.Go 50,000 times?
Click to reveal answer
Answer
It caps live goroutines at 16: when all slots are busy, g.Go blocks the caller until one frees, so the caller acts as dispatcher and memory tracks the limit instead of the task count. Use TryGo when blocking is unacceptable — it returns false instead so you can requeue or shed.
Question
When is errgroup the wrong tool despite its convenience?
Click to reveal answer
Answer
For long-lived workers draining a channel with no batch outcome, a WaitGroup plus results channel fits better; for streams where one bad item should not void the rest, a pipeline with error-carrying result structs preserves throughput; and when you need every failure rather than the first, you must collect errors yourself. errgroup models an abort-on-first-failure batch, not an ongoing flow.
Revision Notes
Key Takeaways
- 1. errgroup replaces WaitGroup + mutex + first-error + cancel with one object whose contract is first-failure-wins
- 2. The derived context dies on first error AND when Wait returns — finish all context-dependent I/O inside the goroutines
- 3. Result collection is mutex-free with pre-sized indexed writes; shared appends still race
- 4. SetLimit converts the group into a bounded pool where g.Go itself applies backpressure
- 5. Batch-with-abort is errgroup's shape; streams and full-error collection are jobs for pipelines and errors.Join
Interview Tips
- • "Run these five calls in parallel and fail fast" — write errgroup.WithContext, pass the derived ctx into each call, wrap errors with %w, and state that Wait returns the first failure
- • "What's the difference between errgroup and WaitGroup?" — WaitGroup only counts lifetimes; errgroup adds error identity (first wins) and automatic sibling cancellation via the derived context
- • "How do you bound concurrency in errgroup?" — SetLimit before the loop, note that g.Go blocks (TryGo doesn't), and tie the number to GOMAXPROCS or the dependency's limit
- • "Gotcha question: why does my fetch fail after Wait succeeds?" — explain context cancellation on Wait plus reading bodies inside the goroutine; this answer signals real production experience
Cheat Sheet
Parallel Work with errgroup
Core contract:
| Event | Effect |
|---|---|
| g.Go(f) launches f | f's returned error is recorded |
| first f returns non-nil error | derived ctx cancelled immediately |
| any f returns non-nil | g.Wait returns that FIRST error; later errors dropped |
| g.Wait() returns | derived ctx cancelled — even on success |
| g.Go with SetLimit active | blocks until a slot frees |
Template:
g, ctx := errgroup.WithContext(parent)
g.SetLimit(16) // before any g.Go
results := make([]R, len(tasks))
for i, t := range tasks {
i, t := i, t
g.Go(func() error {
v, err := do(ctx, t) // ctx-aware work INSIDE the group
if err != nil {
return fmt.Errorf("task %d: %w", i, err)
}
results[i] = v // own slot: no mutex
return nil
})
}
if err := g.Wait(); err != nil {
return err
}
Order of operations: WithContext first → derive children from the group's ctx → SetLimit before first Go → one Wait. Miss any and cancellation or bounding silently fails.