golang.org/x/sync/errgroup has been in my toolbox long enough that I forget not everyone reaches for it. The part I still see people rewrite by hand is “fan out N jobs, bail on the first failure, return a useful error.” That is literally what errgroup.WithContext does.

The trick is to pass the derived context into every worker. When one worker returns an error, the group cancels that context, and every other worker that was respecting it exits quickly. The first non-nil error wins.

func fanOut(ctx context.Context, urls []string) ([]Response, error) {
	g, ctx := errgroup.WithContext(ctx)
	out := make([]Response, len(urls))

	g.SetLimit(8) // cap parallelism

	for i, u := range urls {
		i, u := i, u
		g.Go(func() error {
			r, err := fetch(ctx, u)
			if err != nil {
				return fmt.Errorf("url %s: %w", u, err)
			}
			out[i] = r
			return nil
		})
	}
	if err := g.Wait(); err != nil {
		return nil, err
	}
	return out, nil
}

SetLimit is the bit I forgot about for a year. Without it, you will happily open 10,000 goroutines and exhaust file descriptors. See also /posts/context-context-is-not-a-cache/.