Diagnosing Goroutine Leaks from Blocked Channel Sends in Go
Learn how to spot and fix goroutine leaks caused by blocked channel sends in Go, with a step-by-step diagnostic flow, concrete reproducer, and verification tips.
26 Apr 2026, 08:39 UTC

Recognizable Condition
A steady increase in the number of goroutines reported by runtime.NumGoroutine(), rising memory usage, or latency spikes under load often signals a blocked channel send.
Cause/Diagnostic Table
| Cause | Typical indicator in goroutine stacks |
|---|---|
| Unbuffered channel with no matching receiver | Goroutine stuck on <-ch (send) operation |
| Buffered channel at capacity | Send blocked while channel len == cap |
| Select statement lacking a default case | Goroutine blocked on a case that is a channel send |
| Channel closed before all senders finish | Send causes panic; if recovered, goroutine may linger |
Ordered Checks
- Enable runtime/pprof or run
go tool traceto capture a snapshot of goroutine stacks. - From the profile, filter for lines containing "<-ch" (channel send) to locate blocked sends.
- For each blocked send, verify whether a corresponding receiver goroutine is active or if the channel has been closed.
- Examine the channel’s buffer size (if any) and compare the rate of sends versus receives; a sustained mismatch points to capacity exhaustion.
Fixes Tied to Findings
- Unbuffered channel, no receiver: Add a dedicated goroutine that performs the receive, or redesign the API to provide a receiver.
- Buffered channel full: Increase the buffer size to absorb bursts, or add a receiver to drain the channel.
- Select missing default: Insert a
default:case that performs a non‑blocking operation (e.g., log, drop, or return an error). - Channel closed early: Move the
close(ch)call after all sender goroutines have finished, using async.WaitGroupor similar coordination. - General mitigation: Wrap sends in a
selectwith acontext.Contexttimeout or cancel signal to abort when work is done.
Escalation Criteria
- The goroutine count continues to rise after applying the above fixes.
- The blocked send originates from standard‑library or third‑party code that you cannot modify.
- Zero application‑level sends are observed yet the leak persists, indicating a deeper runtime or scheduler issue.
Limitations and Mitigations
Increasing buffer size can hide a leak but also raise memory usage; treat it as a temporary measure while you add a receiver or fix the send‑receive balance.
Programs that intentionally keep long‑lived goroutines (e.g., worker pools) may show a stable elevated count; verify that the count returns to baseline after the workload ends before concluding a leak.
Example Reproducer and Fix
The following minimal program creates an unbuffered channel and starts a sender goroutine without a receiver. It demonstrates the leak.
func main() {
ch := make(chan int) // unbuffered
go func() {
for i := 0; i < 5; i++ {
ch <- i // blocks forever - no receiver
}
}()
time.Sleep(200 * time.Millisecond)
// In a real leak, the sender would stay blocked forever
Run it with:
GOMAXPROCS=1 go run main.go
You will see the goroutine count rise to 2 (main + blocked sender) and stay there.
Fix: add a receiver goroutine and optionally increase buffer size.
func main() {
ch := make(chan int, 10) // buffered
go func() {
for i := 0; i < 5; i++ {
ch <- i
}
}()
go func() {
for _ := range ch {
// drain
}
}()
time.Sleep(200 * time.Millisecond)
// After fix, the sender completes, the receiver exits
After the fix, the sender completes, the receiver exits, and the goroutine count returns to 1 (only main).
To verify with pprof:
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/goroutine?debug=1
Look for stacks containing "<-ch"; they should disappear after the fix.
Rollback
If you need to revert the changes, simply remove the added receiver goroutine or restore the original buffer size. The program will return to its previous leaking behavior, confirming that the modification affected the goroutine count.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.