go 46 lines · 1 tab

Fan-out with errgroup and shared cancellation

Leah Thompson Jan 2026
1 tab
package service

import (
  "context"

  "golang.org/x/sync/errgroup"
)

type User struct{ ID string }
type Billing struct{ Plan string }
type Features struct{ Flags []string }

type Clients interface {
  FetchUser(ctx context.Context, id string) (User, error)
  FetchBilling(ctx context.Context, id string) (Billing, error)
  FetchFeatures(ctx context.Context, id string) (Features, error)
}

func Aggregate(ctx context.Context, c Clients, id string) (User, Billing, Features, error) {
  g, ctx := errgroup.WithContext(ctx)

  var u User
  var b Billing
  var f Features

  g.Go(func() error {
    var err error
    u, err = c.FetchUser(ctx, id)
    return err
  })
  g.Go(func() error {
    var err error
    b, err = c.FetchBilling(ctx, id)
    return err
  })
  g.Go(func() error {
    var err error
    f, err = c.FetchFeatures(ctx, id)
    return err
  })

  if err := g.Wait(); err != nil {
    return User{}, Billing{}, Features{}, err
  }
  return u, b, f, nil
}
1 file · go Explain with highlit

When you need to call multiple downstream systems, errgroup is a great way to express “do these in parallel, cancel on first failure.” The key is using errgroup.WithContext, not a plain WaitGroup, so the group can propagate cancellation. Each goroutine should select on ctx.Done() (or pass ctx into the client) and return early when canceled, otherwise you get tail-latency even after the request has already failed. I also like keeping results in separate variables and only reading them after g.Wait() returns nil. This avoids subtle data races and keeps the error path obvious. It’s a compact pattern that scales well for aggregate endpoints.


Related snips

Share this code

Here's the card — post it anywhere.

Fan-out with errgroup and shared cancellation — share card
Link copied