At work, a common mistake I notice when reviewing candidates’ home assignments is how they wire goroutines to channels and then return early.
The pattern usually looks like this:
- start a few goroutines
- each goroutine sends a
result
to its own unbuffered channel - in the main goroutine, read from those channels one by one
- if any read contains an error, return early
The trap is the early return. With an unbuffered channel, a send blocks until a receiver is ready. If you return before reading from the remaining channels, the goroutines writing to them block forever. That’s a goroutine leak.
Here’s how the bug appears in a tiny example: one worker intentionally fails, causing the
main goroutine to bail early. That early return skips the receive from ch2
, leaving the
sender on ch2
stuck.
type result struct{ err error }
func Example() error {
ch1 := make(chan result) // unbuffered
ch2 := make(chan result) // unbuffered
// Simulate a failing worker by sending an error into ch1.
// This is intentional to trigger the early return below.
go func() { ch1 <- result{err: fmt.Errorf("oops")} }()
// Simulate a successful worker that will try to send into ch2.
go func() { ch2 <- result{err: nil} }()
// Receive the first result.
res1 := <-ch1
if res1.err != nil {
// We return right away because of the error.
// Because we never read from ch2, the goroutine sending to ch2
// is now blocked forever on its send. That goroutine leaks.
return res1.err
}
// This receive is skipped on the error path above.
res2 := <-ch2
if res2.err != nil {
return res2.err
}
return nil
}
One simple fix is to make sure you always read from both channels before you decide what to do. This guarantees that every send has a matching receive and no goroutine gets stuck:
func ExampleDrain() error {
ch1 := make(chan result)
ch2 := make(chan result)
go func() { ch1 <- result{err: fmt.Errorf("oops")} }() // same failure
go func() { ch2 <- result{err: nil} }() // same success
// Always receive both. Both sends now complete.
res1 := <-ch1
res2 := <-ch2
if res1.err != nil {
return res1.err
}
if res2.err != nil {
return res2.err
}
return nil
}
This is safe but it means you always wait for both workers even when the first one already failed and the second result is irrelevant. If you want to return early without leaking, another option is to use buffered channels so the producers do not block on send. A buffer of size one is enough for this pattern.
func ExampleBuffered() error {
ch1 := make(chan result, 1) // buffered so sends do not block
ch2 := make(chan result, 1)
go func() { ch1 <- result{err: fmt.Errorf("oops")} }() // failure
go func() { ch2 <- result{err: nil} }() // success
// Receive the first result and decide.
res1 := <-ch1
if res1.err != nil {
// Safe to return early. The send to ch2 already completed
// into its buffer even though we have not read it yet.
return res1.err
}
// If we do continue, still read from ch2 to consume the buffered value.
res2 := <-ch2
if res2.err != nil {
return res2.err
}
return nil
}
Buffered channels remove the blocked send, but they also make it easier to forget that a second result exists at all. If that second value carries data you must process, you should still receive it. If it is truly fire and forget, buffering is fine.
Often the cleanest approach is to drop the channel plumbing when you only need to run tasks and aggregate errors. The errgroup package lets each goroutine return an error while the group does the waiting. There is nothing to forget to receive, so there is nothing to leak.
import (
"fmt"
"golang.org/x/sync/errgroup"
)
func ExampleErrgroup() error {
var g errgroup.Group
// Task 1 fails and returns an error.
g.Go(func() error {
return fmt.Errorf("oops")
})
// Task 2 succeeds.
g.Go(func() error {
return nil
})
// Wait waits for both tasks and returns the first error, if any.
return g.Wait()
}
Sometimes you also want peers to stop once one task fails. errgroup.WithContext
gives you
a context that gets canceled as soon as any task returns an error. You pass that context
into your workers and have them check ctx.Done()
so they can exit quickly.
import (
"context"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
func ExampleErrgroupWithContext() error {
// When any task returns an error, ctx is canceled.
g, ctx := errgroup.WithContext(context.Background())
// Task 1 fails quickly to simulate an early error.
g.Go(func() error {
return fmt.Errorf("oops")
})
// Task 2 is long running but cooperates with cancellation.
g.Go(func() error {
for {
select {
case <-ctx.Done():
// Exits because Task 1 failed and canceled the context.
return ctx.Err()
default:
time.Sleep(10 * time.Millisecond)
}
}
})
return g.Wait()
}
At this point it is natural to ask if tools can catch the original bug for you. go vet
cannot. Vet is static analysis that runs at build time. Whether a send blocks depends on
runtime control flow and timing. Vet cannot prove that the function returns before a
particular receive in a general way, so it does not flag this pattern.
go test -race
cannot either. The race detector detects unsynchronized concurrent memory
access. A goroutine stuck on a channel send is not a data race. You may see a test hang
until timeout, but the tool will not point to a leaking goroutine.
You can turn this into a failing test with goleak from Uber. goleak
fails if goroutines
are still alive when a test ends. It snapshots all goroutines via the runtime, filters out
the standard background ones, and reports the rest. Wire it into a test that triggers the
early return and you will see the blocked sender’s stack in the output.
Here is a test that leaks and fails:
package example_test
import (
"fmt"
"testing"
"go.uber.org/goleak"
)
type result struct{ err error }
func buggyEarlyReturn() error {
ch1 := make(chan result)
ch2 := make(chan result)
// Force the early-return path by sending an error on ch1.
go func() { ch1 <- result{err: fmt.Errorf("oops")} }()
// This send will block forever on the failing path
// because nobody receives ch2.
go func() { ch2 <- result{err: nil} }()
r1 := <-ch1
if r1.err != nil {
return r1.err // leak: ch2 sender is stuck
}
_ = <-ch2
return nil
}
func TestBuggyLeaks(t *testing.T) {
// fails if any goroutines are stuck at test end
defer goleak.VerifyNone(t)
_ = buggyEarlyReturn()
}
This test fails and prints the goroutine stack stuck in the send to ch2
.
=== RUN TestBuggyLeaks
main_test.go:34: found unexpected goroutines:
[Goroutine 24 in state chan send, with thing.buggyEarlyReturn.func2 on
top of the stack:
thing.buggyEarlyReturn.func2()
/Users/rednafi/canvas/rednafi.com/thing/main_test.go:20 +0x28
created by thing.buggyEarlyReturn in goroutine 22
/Users/rednafi/canvas/rednafi.com/thing/main_test.go:20 +0xc0
]
--- FAIL: TestBuggyLeaks (0.44s)
FAIL
exit status 1
If you switch the implementation to a fixed version, the test passes. For example, the draining fix:
func fixedDrain() error {
ch1 := make(chan result)
ch2 := make(chan result)
go func() { ch1 <- result{err: fmt.Errorf("oops")} }()
go func() { ch2 <- result{err: nil} }()
r1 := <-ch1
r2 := <-ch2
if r1.err != nil {
return r1.err
}
if r2.err != nil {
return r2.err
}
return nil
}
func TestFixedNoLeaks(t *testing.T) {
defer goleak.VerifyNone(t)
_ = fixedDrain()
}
If you prefer suite wide enforcement, add goleak to your TestMain
. This way your entire
test run fails if any test leaks goroutines.
package main
import (
"os"
"testing"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
// VerifyTestMain wraps the whole test run
// and fails if any goroutines are left behind.
goleak.VerifyTestMain(m)
}
If you start goroutines that send on channels, think carefully about early returns. An unbuffered send waits for a receive, and if you return before that receive happens, you’ve leaked a goroutine.
You can avoid this by:
- always draining all channels
- buffering intentionally so sends don’t block
- or using
errgroup
, with or without context, so tasks return errors and cooperate on cancellation
Add goleak to your tests so leaks surface early during development.
Recent posts
- Lifecycle management in Go tests
- Gateway pattern for external service calls
- Flags for discoverable test config in Go
- You probably don't need a DI framework
- Preventing accidental struct copies in Go
- Go 1.24's "tool" directive
- Capturing console output in Go tests
- Deferred teardown closure in Go testing
- Three flavors of sorting Go slices
- Nil comparisons and Go interface