Should I run several dependent operations with errgroup so the first error cancels the rest?
Question
In an API we write in Go, at the start of every request we make five parallel HTTP calls to separate upstream services (user, subscription, campaign, inventory and pricing). Right now I fan them out with a plain `sync.WaitGroup` and collect all the results. The problem: the moment one of these five fails, there's no point waiting for the other four, because I'll finish the request with an error anyway. Should I move to `errgroup` to abort the whole batch on the first error, or is that overkill?
Answer
Short answer: yes, move to it.
Short answer
This is precisely the scenario errgroup.WithContext exists to solve; it’s not overkill here, it’s the right tool. sync.WaitGroup gives you “wait for all”; what you want is “if one falls, drop the rest”. errgroup is a small, mature dependency living under golang.org/x/sync/errgroup, and it’s well worth pulling in for this kind of fan-out.
Why
-
errgroup.WithContext is the idiom.
WithContexthands the group a derived context and cancels it the moment any goroutine returns a non-nilerror. So the first error automatically sends a “stop” signal to the remaining calls; you don’t have to build any signalling yourself. -
Cancellation is cooperative — your calls must honor ctx. This is the most commonly missed point. Even if
ctxis cancelled, if your HTTP call doesn’t carry that context viahttp.NewRequestWithContext, cancellation has zero effect; the call keeps running until it finishes. Same goes for DB queries. If you don’t thread the context all the way down, half of errgroup’s value is wasted.g, ctx := errgroup.WithContext(ctx) results := make([]Result, len(ids)) for i, id := range ids { i, id := i, id // loop-var capture, pre-Go 1.22 g.Go(func() error { r, err := lookup(ctx, id) // ctx flows into http.NewRequestWithContext inside if err != nil { return fmt.Errorf("lookup %s: %w", id, err) } results[i] = r return nil }) } if err := g.Wait(); err != nil { return nil, err // the FIRST error only } -
g.Wait() returns only the first error. The other goroutines’ errors are dropped. If you need to collect the errors from all five calls, errgroup is the wrong tool; you’d want
WaitGroup+ a channel orerrors.Join. But since your need is “first one’s enough, cancel the rest”, it fits exactly. -
Wait() still blocks until all goroutines return. Cancellation says “stop”, but
Waitkeeps blocking until each goroutine has actually returned. If you have a call that ignores ctx, you’ll still wait as long as the slowest one — which is why point 2 is critical.
What to do
-
Don’t write shared results without synchronization. If each goroutine writes to its own
results[i]index it’s safe (separate memory cells). Write to a sharedmapand you get a data race — caught by-race. Use slice indices or a mutex. -
SetLimit if the fan-out grows. Unbounded concurrency is fine for five calls, but if that number grows, cap concurrent goroutines with
g.SetLimit(n); don’t hammer your upstreams. -
Put a deadline on the parent context. Cancellation only fires if one of the calls returns an error. If none error but one hangs (a slow upstream, an unresponsive socket),
errgroupwaits forever. Guard against that by wrapping the context you pass toWithContextwithcontext.WithTimeout; when the deadline hits, ctx is cancelled, every call that honors ctx returns, and your request finishes within a bounded time.
Bottom line: personally I’d switch to errgroup.WithContext, put a request-level timeout on top of it, thread ctx down into every HTTP/DB call, and write results into a per-index slice. On Go 1.22+ you can even drop the loop-var capture line. In short: set up both guarantees together — “drop the rest if one falls” and “time out if nobody returns”.
Related Reading
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.