# Should I run several dependent operations with errgroup so the first error cancels the rest?

> Move to `errgroup.WithContext` with a request-level timeout on top and thread ctx into every HTTP and DB call; an ignored ctx means no cancellation at all.

- Asked: 2026-08-30
- Answered: 2026-09-04
- Asked by: Onur
- Tags: go, concurrency
- Source: https://www.muhammetsafak.com.tr/en/just-ask/should-i-run-several-dependent-operations-with-errgroup-so-the-first/
- Language: en-US
- Author: Muhammet Şafak

---
**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?


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

1. **errgroup.WithContext is the idiom.** `WithContext` hands the group a derived context and cancels it the moment any goroutine returns a non-`nil` error. So the first error automatically sends a "stop" signal to the remaining calls; you don't have to [build any signalling yourself](/en/blog/concurrency-in-go-goroutines-and-channels-in-practice/).

2. **Cancellation is cooperative — your calls must honor ctx.** This is the most commonly missed point. Even if `ctx` is cancelled, if your HTTP call doesn't carry that context via `http.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.

   ```go
   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
   }
   ```

3. **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 or `errors.Join`. But since your need is "first one's enough, cancel the rest", it fits exactly.

4. **Wait() still blocks until all goroutines return.** Cancellation says "stop", but `Wait` keeps 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

1. **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 shared `map` and you get a data race — caught by `-race`. Use slice indices or a mutex.

2. **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.

3. **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), `errgroup` waits forever. Guard against that by wrapping the context you pass to `WithContext` with `context.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

- [Concurrency in Go: goroutines and channels in practice](/en/blog/concurrency-in-go-goroutines-and-channels-in-practice/) — Blog
- [How do I propagate context cancellation correctly through nested service calls?](https://www.muhammetsafak.com.tr/en/just-ask/how-do-i-propagate-context-cancellation-correctly-through-nested-service-calls/) — Just Ask
- [How do I detect and prevent goroutine leaks in a long-running Go service?](https://www.muhammetsafak.com.tr/en/just-ask/how-do-i-detect-and-prevent-goroutine-leaks-in-a-long/) — Just Ask
- [How do I distribute a Go CLI to multiple platforms with GoReleaser and Homebrew?](https://www.muhammetsafak.com.tr/en/just-ask/multi-platform-go-cli-distribution-with-goreleaser-and-homebrew/) — Just Ask
