open atlas
↑ Back to track
Go, zero to senior GO · 10 · 01

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.

GO Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

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.

Quiz

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 succeeded

errors.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.

Quiz

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.

Pick the best fit

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?

Recall before you leave
  1. 01
    What does errgroup.WithContext give you over a raw WaitGroup, and where exactly does cancellation happen — and not happen?
  2. 02
    Describe the first-error-wins semantics and the two fan-out failure policies it forces you to choose between.
Recap

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.

recallapplystretch0 of 6 done

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.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.