Here is some code you have probably written before, and which runs in production at a lot of companies. A dashboard aggregates three calls — preferences, profile, orders — and since 1.2 seconds is too slow, we bound the latency.
var wg sync.WaitGroup
wg.Go(func() {
o, err := adapters.FetchOrders(ctx, userID)
if err != nil {
return
}
orders = o
})
// ... same for profile and preferences
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
return domain.Dashboard{Profile: profile, Preferences: prefs, Orders: orders}, nil
case <-time.After(500 * time.Millisecond):
return domain.Dashboard{Profile: profile, Preferences: prefs, Orders: orders}, ErrBudgetExceeded
}
It's readable, go vet is happy, and it returns in 500 ms as requested
(wg.Go has existed since Go 1.25; it's Add/Done without the ceremony).
Let's add a few timestamped logs and see what really happens: preferences
fails at 300 ms with a 503, profile answers at 900 ms, orders at 1.2 s.
[ 301ms] preferences ✗ ERROR: preferences service unavailable (HTTP 503)
[ 501ms] use case ⏱ giving up after 500ms — the goroutines carry on
[ 901ms] profile ← written for a caller that is long gone
[ 1220ms] orders ← written for a caller that is long gone
The function returned at 501 ms. Four hundred milliseconds later, a goroutine
writes to profile; seven hundred, another one writes to orders. They are
dutifully filling in a dashboard that nobody will ever read.
Go has no implicit join
This is the sentence everything else follows from: a goroutine has no parent.
Elsewhere, you get a handle — a Thread, a Future, a Task — that you can
wait on, sometimes cancel, at the very least query. In Go, go f() returns
nothing. No handle, no lineage, no lifetime tied to the calling function.
So return kills nothing. It leaves the current function, without notifying or
interrupting anyone. A WaitGroup knows how to wait — that is all it knows how
to do — and the second you decide to stop waiting, you no longer have any hold
on what you started.
With a WaitGroup, you choose between blocking and leaking. There is no third
option.
The test that's green while the bug is right there
These goroutines write to profile and orders — the very variables the
select just read to build the return value. That is a data race, in the strict
sense.
func TestTimeoutWritesAfterReturn(t *testing.T) {
_, err := GetDashboardWithTimeout(context.Background(), "1")
if !errors.Is(err, ErrBudgetExceeded) {
t.Fatalf("expected the budget to be exceeded, got %v", err)
}
time.Sleep(2 * time.Second) // let the orphans write
}
$ go test ./internal/use_cases/
ok bailleul.dev/articles-errgroup/internal/use_cases 2.830s
$ go test -race ./internal/use_cases/
WARNING: DATA RACE
Write at 0x00c0000941e0 by goroutine 9:
usecases.GetDashboardWithTimeout.func2()
internal/use_cases/get_dashboard_1_timeout.go:55
Previous read at 0x00c0000941e0 by goroutine 7:
usecases.GetDashboardWithTimeout()
internal/use_cases/get_dashboard_1_timeout.go:81
[...]
--- FAIL: TestTimeoutWritesAfterReturn (2.50s)
testing.go:1865: race detected during execution of test
Two reports, one per surviving goroutine.
Remember the contrast: the suite is green, the bug is there, only -race sees
it. And this is not just "a potentially stale value". Go's memory model only
bounds the damage for values that fit in a machine word. A slice, a string, an
interface take several: the read can grab a pointer that doesn't match its
length, and the crash will happen very far from the scene of the crime.
The aside that cost me an hour
While instrumenting this demo, I had added timestamped logs, protected by a
mutex since several goroutines write to stdout. And -race went quiet. Zero
races, while the bug was intact.
The detector only reports accesses it cannot order, and a mutex creates order: it is a happens-before relationship. My goroutine took the logger's mutex right before writing to the shared variable; the caller had taken it a little earlier, around its read. That was all it took for the read/write pair to vanish from the report. My instrumentation had synchronized the bug I was trying to show.
The fix: write first, log afterwards.
The lesson goes beyond the demo: a silent -race does not prove the absence of
a race. Your logs, your metrics, your traces — anything that takes a lock —
can hide in tests a race that will show up in production, the day the debug log
gets turned off.
"I'll fix it with a channel"
The problem is the shared variables. Let's replace them with channels; Go has been telling us since day one: don't communicate by sharing memory, share memory by communicating.
ordersCh := make(chan domain.Orders)
go func() {
o, err := adapters.FetchOrders(ctx, userID)
if err != nil {
return
}
ordersCh <- o
}()
deadline := time.After(500 * time.Millisecond)
for range 3 {
select {
case o := <-ordersCh:
dash.Orders = o
// ... profile, preferences
case <-deadline:
return dash, ErrBudgetExceeded
}
}
It works: -race is clean, no more races. It is also strictly worse.
The goroutines reach ordersCh <- o and never leave. The channel is unbuffered,
the only receiver left at 500 ms, and a send with no receiver blocks forever. A
blocked goroutine never returns, so it never frees anything — and in this demo,
orders holds an 18 MB result.
Before, the orphans finished their work and died: the leak was real but temporary. A temporary leak has just become permanent. By fixing the data race.
At server scale
One request leaking 18 MB never killed anybody. A server taking thousands of
them is another story. Let's put these two versions behind an http.Handler —
plus, as a sneak preview, the one this whole article is heading towards. Twenty
sequential requests each, a fresh server every time, measured after a forced
runtime.GC():
| version | right after | 3 s later |
|---|---|---|
| hand-rolled timeout | 9 goroutines, 0 MB | 4 goroutines, 0 MB |
| channels | 44 goroutines, 341 MB | 44 goroutines, 379 MB |
| errgroup | 4 goroutines, 0 MB | 4 goroutines, 0 MB |
The idle server is 4 goroutines: 44 − 4 = 40, that is two blocked goroutines per request, and 341 MB ≈ 20 × 18 MB.
The first row surprised me: the hand-rolled timeout leak costs nothing in memory. Those goroutines die, so they drain as fast as they are created. If that were your only bug, your memory graphs would never alert you. The second one doesn't come back down — it keeps climbing, while the last goroutines finish their computation before freezing — and it never will: from the runtime's point of view, they are just waiting their turn.
Where are they, exactly
This is where net/http/pprof earns the import line it costs.
$ curl -s 'localhost:8080/debug/pprof/goroutine?debug=1'
19 @ ...
# usecases.GetDashboardViaChannels.func2 get_dashboard_2_channels.go:49
18 @ ...
# usecases.GetDashboardViaChannels.func3 get_dashboard_2_channels.go:59
Line 59 is ordersCh <- o; line 49, the profile send. Dozens of copies of the
same stack, all stopped on the same line: you've got your leak, no intuition
required. Mount it on an internal port right now — the day the heap climbs, the
question gets settled in ten seconds.
The ingredient I had to add
An honest aside: the first version of the server did not leak. Flat curve, with the faulty code nonetheless.
net/http cancels r.Context() as soon as the handler returns, and that context
went all the way down to the adapters, which listen to it. The goroutines were
interrupted before they even reached their send. The platform was catching the
bug for me. To reproduce the leak, I had to write this:
ctx := context.WithoutCancel(r.Context())
That line is everywhere. A team sees context canceled errors going by in its
logs, concludes that cancellation is the problem rather than the symptom, and
detaches the context. The errors disappear — and with them the only thing that
was still telling the goroutines to stop.
errgroup, at last
All the leaks we've seen so far are symptoms of the same gap, and it was already
there in the first snippet: wg.Go takes a func(), not a func() error. The
goroutine that fails can only return. Nobody propagates the error, nobody
warns the others.
golang.org/x/sync/errgroup fills exactly that gap:
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
o, err := adapters.FetchOrders(ctx, userID)
if err != nil {
return fmt.Errorf("fetch orders: %w", err)
}
orders = o
return nil
})
// ... profile, preferences
if err := g.Wait(); err != nil {
return domain.Dashboard{}, err
}
Three things change.
The error propagates, wrapped, inspectable with errors.Is.
The first error cancels the others. 300 ms instead of 1.2 s — including in the middle of a computation, if your loops check their context. The CPU also needs to be told to stop.
No goroutine outlives the call. g.Wait() only returns once they have all
finished: nothing to leak, and no race either, since Wait itself establishes
the happens-before that was missing. That is structured concurrency: what goes
into the function comes back out.
Incidentally, the previous leak had a three-character fix:
make(chan domain.Orders, 1), on each channel. The send no longer blocks, the
goroutine dies, the memory goes away. It plugs the leak without addressing the
swallowed error or the wasted work. It's a band-aid; errgroup is a treatment.
The trap that cancels out the whole benefit
g, ctx := errgroup.WithContext(ctx)
The original ctx is deliberately shadowed: it is the new one that must
reach the outgoing calls. Keep the old one out of habit and all you have left is
a WaitGroup with a nicer API.
Worse, because more discreet: an adapter that doesn't listen to its context.
Without a case <-ctx.Done(), the group can cancel all it wants, the goroutine
sleeps in its call and will run to completion. Concretely:
http.NewRequestWithContext and not http.NewRequest, QueryContext and not
Query. Every blocking function without a context.Context is a hole in your
cancellation. errgroup propagates a signal; it doesn't force anyone to listen
to it.
Three details before you dive in: g.Wait() only returns the first error;
g.SetLimit(n) bounds concurrency in one line; and the group's ctx is
canceled as soon as Wait() returns, even on success.
What to take away
return doesn't kill your goroutines, because in Go nothing kills them: they
stop when they are done, or when they have been given a way to know they should
stop.
Look at the road travelled without ever writing an obviously stupid line.
Bounding latency gives you a data race. Fixing it with channels, which Go has
always recommended, turns a temporary leak into a permanent one. Detaching a
context to silence context canceled errors removes the last safety net. Each
decision is defensible on its own; together, they make an incident.
errgroup fixes all of that at once: the error propagates, the siblings are canceled, nothing outlives the call. What the caller does with that error is its own business.
Three reflexes, even if you remember nothing else:
- Every blocking function takes a
context.Context, and your compute loops check it. Cancellation only propagates where someone listens for it. net/http/pprofon an internal port, right now.- A green
-raceproves nothing if your logging locks cross the suspect path.
The repository contains the three versions, the server and the test:
go test -race ./... # fails, on purpose
go run ./cmd/server # then scripts/load-channels.sh
