errgroup as structured concurrency: scoped goroutines, first-error cancellation, and SetLimit
errgroup scopes goroutines to a region: Go starts children, Wait joins all and returns the first error, WithContext cancels siblings cooperatively — every child must watch ctx.Done. SetLimit adds bounded parallelism; raw WaitGroup offers neither errors nor cancellation.
Black Friday, 14:02. The checkout service fans out to four downstreams per order — inventory, pricing, fraud, loyalty — under an errgroup. At 14:02 the fraud provider started rejecting with a clean error in 80 ms. Failing fast should have been the cheap case. Instead p99 for failed checkouts hit 28 seconds and the goroutine count tripled, because one sibling — the pricing client — built its request with context.Background(). errgroup did exactly what it promises: the first error cancelled the group context 80 ms in. But cancellation in Go is a signal, not a kill. Wait blocks until every child returns, and the pricing call was not listening, so every failed checkout dragged a full 28-second client timeout behind it. The fix was one line — http.NewRequestWithContext(ctx, ...) — and the lesson outlived the incident: errgroup gives you a scope, and a scope is only as tight as its least cooperative member.
A scope for goroutines
When you fan out without a scope, you are trusting that every goroutine returns on its own — and one deaf sibling at 2 a.m. proves that trust wrong. A bare go statement is unstructured: the goroutine has no join point, its error has nowhere to go, and its lifetime is unrelated to the function that spawned it. Structured concurrency inverts that — concurrent work is bounded by a lexical region, and the region does not exit until every child has. In Go the idiom is errgroup:
func loadOrder(ctx context.Context, id string) (*Order, error) {
g, ctx := errgroup.WithContext(ctx) // shadowed ctx: cancelled on first error
var inv Inventory
var price Pricing
g.Go(func() error { return fetchInventory(ctx, id, &inv) })
g.Go(func() error { return fetchPricing(ctx, id, &price) })
g.Go(func() error { return checkFraud(ctx, id) })
if err := g.Wait(); err != nil {
return nil, err // first error; group ctx is already cancelled
}
return assemble(inv, price), nil
}Three guarantees define the region. Wait returns only after every g.Go callback has returned — no child outlives the function. The error Wait returns is the first non-nil one any child produced. And with WithContext, the group context is cancelled as soon as that first failing callback returns — siblings get a shutdown signal without you wiring one. A quieter guarantee rides along: Wait is a happens-before edge, so the pattern above — each child writes its own variable, the parent reads them only after Wait — is race-free without a single mutex.
Cancellation is a signal, not a kill
Here is the subtlety the Hook paid 28 seconds for. errgroup cancels the context after the failing callback returns its error — and cancelling a context does nothing by itself. No goroutine is interrupted, no connection is closed unless something is watching ctx.Done(). In-flight siblings keep running until they observe cancellation, which means the leak rule for the whole unit: every g.Go body must have a return path through ctx. Pass the group ctx into every blocking call — http.NewRequestWithContext, db.QueryContext, and every channel operation wrapped in a select with a ctx.Done() case. A child that computes a hash in a tight loop for 4 seconds is just as deaf: it should check ctx.Err() between chunks. The compiler will not flag a sibling that ignores ctx; your p99 will.
Three g.Go children are running; the fraud check returns an error after 80 ms while the other two are mid-request. What does errgroup do at that moment?
First error wins — so design the failure policy
Internally the group keeps one error slot guarded by sync.Once: the first non-nil return is stored, every later error is discarded. That default encodes an all-or-nothing policy — perfect for “assemble a page from three mandatory calls”, wrong for “enrich 50 records, keep what succeeded”. For best-effort fan-out, do not return errors from the callbacks at all; record them:
errs := make([]error, len(parts))
var g errgroup.Group // no WithContext: nothing should cancel the siblings
for i, p := range parts {
g.Go(func() error {
errs[i] = process(p) // record per-slot; disjoint indices need no mutex
return nil // never trip the group
})
}
_ = g.Wait()
return errors.Join(errs...) // nil only if every part succeedederrors.Join gives you the full picture the group would have thrown away. And even in all-or-nothing mode, wrap each child’s error with identity — fmt.Errorf("pricing: %w", err) — because the one error that survives should at least say which of the twelve calls it came from. Choose the policy per fan-out, explicitly; the silent default has a way of becoming an accidental contract.
SetLimit: bounded parallelism built in
Fan-out over 10,000 items must not mean 10,000 concurrent goroutines hammering one downstream. Since errgroup got SetLimit, the bound is one line:
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // set before any Go; changing it while children are active panics
for _, item := range items {
g.Go(func() error { return convert(ctx, item) }) // blocks while 8 are in flight
}
err := g.Wait()The mechanism matters: when the limit is reached, g.Go blocks the caller. The spawning loop itself becomes a backpressured producer — memory stays bounded by the limit, not by the item count. TryGo is the non-blocking variant: it returns false instead of waiting, for reject-instead-of-queue designs. This is the same block-or-reject choice the backpressure lesson builds on.
A group has SetLimit(8) and 8 children currently running. The loop calls g.Go for item 9. What happens?
▸Why this works
Why does Wait refuse to return early, even when the answer — the first error — is already known? Because early return is exactly how unstructured leaks are born: the parent moves on, the orphaned children still hold buffers, sockets, and the request ctx, and nothing will ever join them. Structured concurrency trades latency honesty for lifetime honesty — if Wait is slow, a child is slow, and the profile points straight at it. The 28-second p99 from the Hook was not errgroup being wasteful; it was errgroup refusing to hide a sibling that could not be cancelled.
WaitGroup, errgroup, or channels of results
Before you reach for a channel of results, ask: do you actually need every outcome as data, or do you just need the first failure and a clean join? Three tools, one decision. sync.WaitGroup is a pure join: no errors, no cancellation — right when children genuinely cannot fail, and a known trap otherwise (errors get logged into the void and the parent reports success). errgroup adds first-error propagation, sibling cancellation, and a concurrency limit — the default for fan-out with a failure policy. Channels of results are the heavyweight option: each worker sends a typed result or error, the consumer ranges and decides — necessary when you need every outcome as data, streaming order, or partial consumption, at the price of writing the close-and-drain choreography yourself (the pipelines lesson). Overhead is honest noise: errgroup is a WaitGroup plus a mutex, a Once, and an optional limiter channel — nanoseconds next to any call worth fanning out for.
You fan out to 5 downstream APIs per request; any failure should cancel the rest and the caller needs exactly one error. Which concurrency tool fits best?
- 01What does errgroup.WithContext give you over a raw WaitGroup, and where exactly does cancellation happen — and not happen?
- 02Describe the first-error-wins semantics and the two fan-out failure policies it forces you to choose between.
Now when you see a Wait that takes far longer than any single child should, look for the sibling that ignores ctx — that is where the scope is leaking. errgroup is Go’s working form of structured concurrency: g.Go starts children, g.Wait joins every one of them and returns the first error, and WithContext wires a shared context that is cancelled the moment the first failing callback returns. The discipline that makes the scope real is cooperative cancellation — cancelling a ctx interrupts nothing by itself, so every child needs a return path through ctx: requests built with NewRequestWithContext, queries through QueryContext, channel operations inside select with a ctx.Done case, CPU loops checking ctx.Err between chunks. A single deaf sibling stretches Wait to that sibling’s own timeout, which is the honest behavior — early return would orphan goroutines still holding buffers and sockets. Error semantics are first-wins behind a Once: later errors are discarded, so pick the failure policy deliberately — all-or-nothing with WithContext and wrapped, attributable errors, or best-effort with no group cancellation, per-slot recording, and errors.Join after Wait. SetLimit makes the group a bounded-parallelism tool: at the limit g.Go blocks the spawning loop itself, bounding memory by the limit instead of the workload, with TryGo as the reject-fast variant — and SetLimit must be set before any Go, since resizing a live group panics. Against the alternatives: WaitGroup is join-only, channels of results buy full per-result data at the cost of hand-rolled close-and-drain choreography. Every g.Go body answers one question or leaks: how does this goroutine end when the region is told to stop?
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.