Context, errgroup, and Functional Options: Go's Concurrency Trinity
A practical look at Go’s concurrency trinity errgroup, context, and Functional Options and how they work together to build fail-fast, cancellable, and cleanly configurable systems.
There's a specific kind of humbling that happens when you think you understand something and then Go shows you the version of it that actually works in production.
That happened to me with concurrency.
I Thought I Got It
I knew goroutines. I knew sync.WaitGroup. Spin up a few goroutines, call wg.Add(1), defer wg.Done(), then wg.Wait() at the bottom. Done. Concurrent. Ship it.
And look it works. For a while.
The problem isn't that WaitGroup is wrong. The problem is what it doesn't do. And in production systems, what a tool doesn't do is usually the thing that burns you.
WaitGroup has no opinion on errors. If you spawn 10 goroutines and one of them fails spectacularly at the 3-second mark, WaitGroup doesn't care. It waits for the other 9 to finish, however long that takes, before you even find out something went wrong.
That's not fail-fast. That's fail-eventually-and-hope-you-noticed.
The Problem Isn't Just Errors. It's Wasted Work.
Think about it like this. You're building a user dashboard. To render that page, you need data from a Profile service and an Order service. You fire both requests in parallel smart, saves latency.
But the Profile service dies immediately. Error. Gone.
With WaitGroup, your Order service request is still running. Fetching data. Using resources. Taking its sweet time. And when it finally finishes you throw the result away, because the whole operation already failed.
You just paid for work you didn't need.
errgroup fixes this. When one goroutine errors, it cancels a shared context. Every other goroutine holding that context suddenly knows: we're done here. They can check, bail out, and stop burning resources. The whole fan-out collapses cleanly.
That's the mindset shift. From "wait for everyone" to "the moment one person falls, we all stop."
Meet the Trinity
Three patterns make this work together. They're not optional extras — they're load-bearing. Miss one and the whole thing wobbles.
1. errgroup: The Upgrade You Didn't Know You Needed
errgroup is from golang.org/x/sync. It's not in the standard library, which I think is why people sleep on it. But it should be your default anytime you're running goroutines that can fail.
Here's the basic shape:
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error {
return fetchProfile(ctx, userID)
})
g.Go(func() error {
return fetchOrders(ctx, userID)
})
if err := g.Wait(); err != nil {
return err
}g.Wait() blocks until all goroutines are done — or until one of them returns an error. If that happens, the shared ctx gets cancelled, and g.Wait() returns that error the moment everything wraps up.
No more silent failures. No more blind waiting.
2. Context: Not Just a Parameter, a Contract
If errgroup is the alarm system, context.Context is the wiring that makes the alarm reach every room.
Context is Go's way of saying: this operation has a lifetime. When it ends, everything downstream ends with it.
You pass it as the first argument to your methods. Always. That's not just style it's a contract. It tells anyone reading your code: this function respects cancellation. It won't keep running after the parent operation dies.
When the Profile service fails and errgroup cancels the context, the Order service's HTTP client checks that context on every operation. If the context is cancelled, it returns immediately. No 10-second wait. No wasted roundtrip.
That's the domino effect working for you, not against you.
func fetchOrders(ctx context.Context, userID int) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", ordersURL, nil)
if err != nil {
return "", err
}
// if ctx is cancelled before this runs, the request never fires
resp, err := http.DefaultClient.Do(req)
...
}3. Functional Options: Because Constructors Lie
Okay, this one's less dramatic but it solves a real pain.
Imagine your UserAggregator needs a timeout. And a logger. And maybe a retry count later. And maybe a custom HTTP client.
The naive solution is a constructor that takes all of that:
func New(timeout time.Duration, logger *slog.Logger, retries int) *UserAggregator
This is called "parameter soup" and it is, in fact, a dumpster fire. Every time you add a new option, you break every caller. And half the time callers pass zero values because they don't care about a specific option, but they have to pass something.
Functional Options solve this cleanly:
type Option func(*UserAggregator)
func WithTimeout(d time.Duration) Option {
return func(a *UserAggregator) {
a.timeout = d
}
}
func WithLogger(l *slog.Logger) Option {
return func(a *UserAggregator) {
a.logger = l
}
}
func New(opts ...Option) *UserAggregator {
a := &UserAggregator{
timeout: 5 * time.Second, // sane defaults
logger: slog.Default(),
}
for _, opt := range opts {
opt(a)
}
return a
}Now callers only configure what they care about:
agg := New(WithTimeout(2 * time.Second))
Adding new options in the future doesn't touch existing callers. It's extensible by default, and the constructor stays clean no matter how complex the config gets.
How They Fit Together
This is where it clicks. These three patterns aren't independent they're designed to work as a unit.
Functional Options give you a cleanly configured aggregator. Context flows through every method call, carrying the lifetime of the operation. errgroup uses that context to wire up fail-fast cancellation across concurrent goroutines.
Pull any one piece out and something breaks. No Functional Options and your constructor becomes a mess you're afraid to touch. No Context propagation and your goroutines can't talk to each other about cancellation. No errgroup and you're back to waiting blindly for work that doesn't matter.
Together, the flow looks like this:
Aggregate(ctx, id)
│
├── errgroup.WithContext(ctx) ← shared cancellable context
│ │
│ ├── goroutine: fetchProfile(gCtx, id)
│ └── goroutine: fetchOrders(gCtx, id)
│
└── g.Wait() ← first error cancels gCtx, all goroutines bail
The moment anything fails, the context carries that signal downstream. Every goroutine checks it, sees the cancellation, and stops. g.Wait() returns the error. You move on.
Clean. Fast. No waste.
The Part That Actually Surprised Me
The thing I didn't expect: this isn't advanced Go. It's idiomatic Go.
sync.WaitGroup exists. It's useful. But the moment your goroutines can fail and in any real system, they can errgroup is what you reach for. Context propagation isn't optional ceremony; it's how Go communicates across goroutine boundaries. Functional Options aren't clever engineering flex; they're how Go keeps packages extensible without breaking things.
These three patterns show up in production Go codebases everywhere. Not as special cases. As defaults.
I'm still early in really internalizing this. But I'm starting to see the shape of what idiomatic Go looks like at this level and it's less about syntax and more about what you reach for first when something needs to be concurrent, configurable, and cancellable.
That instinct is what I'm building now.
Comments