Goroutine Leak via Blocked Channel
A goroutine that blocks forever on a channel operation is leaked: it will never run again, never be garbage-collected, and never release the stack and captured variables it holds. The Go runtime has no goroutine timeout and no goroutine cancellation primitive — a goroutine ends only by returning from its top function or by the process exiting. So a goroutine parked on a send or receive that can never be satisfied is a permanent resource leak. This note covers two specific, extremely common shapes of that bug: the unbuffered send with no surviving receiver, and the abandoned
selectwhere every case’s channel is dead. The general phenomenon, its detection tooling, and other leak sources are covered in Goroutine Leaks — this note does not repeat that; it drills into the blocked-channel instance.
Mental Model
A goroutine is cheap but not free: a live goroutine holds a stack (starting around 8 KiB and growable), the closure variables it captured, and any objects reachable from those. Because the leaked goroutine is itself still “running” from the runtime’s point of view — merely parked — everything it references stays reachable, so the garbage collector cannot reclaim any of it. A program that leaks one goroutine per request leaks one stack and one request’s worth of objects per request, forever. The symptom is a slow, monotonic climb in goroutine count and heap size that ends in an out-of-memory crash hours or days later.
The root cause is always a broken channel contract. A channel operation has an implicit counterparty: a send needs a receiver, a receive needs a sender or a close. When the counterparty disappears — the receiver returned early, the sender was never started, the producer was abandoned — the surviving side blocks with no possible wakeup.
flowchart TD REQ["request handler starts a goroutine"] --> G["goroutine: ch <- result"] REQ --> CTX{"caller still<br/>waiting on ch?"} CTX -->|"yes — receives"| OK["goroutine unblocks, returns, freed"] CTX -->|"no — returned early<br/>(timeout / context cancel / early return)"| LEAK["ch has no receiver, ever"] LEAK --> BLOCK["goroutine parked on send forever"] BLOCK --> HELD["stack + captured vars + reachable objects<br/>pinned, never GC'd"] HELD --> GROW["goroutine count and heap climb → OOM"]
Diagram: the leak is born the moment the caller stops waiting on a channel but the goroutine still intends to send on it. The insight: the bug is not in the goroutine’s code in isolation — it is in the mismatch between the goroutine’s lifetime assumption (“someone will receive”) and the caller’s actual behavior (“I left early”).
Mechanical Walk-through
Why a blocked channel op never wakes
From src/runtime/chan.go: when a send cannot complete (no waiting receiver, no buffer room), the runtime calls gopark, which moves the goroutine off its OS thread, sets its state to waiting, and records it in the channel’s sendq queue. The only events that take it back off sendq are: a receiver arriving on that channel, or the channel being closed. If neither ever happens, the goroutine stays in sendq for the life of the process. There is no timer, no deadline, no signal — gopark with no waker is a permanent park.
Receiving is symmetric: a blocked receive parks the goroutine in recvq, woken only by a send or a close. So a receiver waiting on a channel that no one will ever send to and no one will ever close is equally leaked.
Why the runtime’s deadlock detector does not catch it
Go’s runtime panics with fatal error: all goroutines are asleep - deadlock! only when every goroutine is blocked. A leaked goroutine in a real server is invisible to that check, because the rest of the program — the HTTP server, other handlers, the GC — is still running. The deadlock detector catches total deadlock; it cannot catch one stuck goroutine among thousands of healthy ones. That is why channel leaks survive into production: nothing crashes, nothing logs, the goroutine count just creeps up.
Shape 1: unbuffered send, receiver gone
The classic generator/pipeline bug (Go Blog: Pipelines). A function spawns a goroutine that produces values onto an unbuffered channel and returns the channel to a caller. The caller is supposed to drain the channel. If the caller stops draining early — it found what it wanted, or its context was cancelled, or it hit an error and returned — the producer goroutine is mid-send (or about to send) on a channel with no receiver. Because the channel is unbuffered, the send has nowhere to put the value (see Buffered vs Unbuffered Channel Mistakes), so the producer parks forever.
Shape 2: abandoned select
A goroutine sits in a select waiting on several channels. If, over time, every one of those channels becomes permanently un-actionable — the result channel will never be sent to, the cancellation channel will never be closed, the timer was never created — the select blocks with no live case. A select with no default and no actionable case is exactly as stuck as a bare blocked channel op. This frequently happens when a select watches a result channel and a “done” channel, but the code path that would have produced the result or closed done was never reached.
Code Examples
Shape 1 — generator leak, and the fix
// LEAKY: producer sends on an unbuffered channel; caller may stop early.
func numbers() <-chan int {
ch := make(chan int) // unbuffered
go func() {
for i := 0; ; i++ {
ch <- i // (A) blocks forever once caller stops receiving
}
}()
return ch
}
func main() {
for n := range numbers() {
if n == 3 {
break // caller leaves — goroutine stuck at (A)
}
}
// the numbers() goroutine is now leaked permanently
}The break ends the range, so nothing ever receives again. The producer, having sent 0,1,2,3, attempts to send 4 at (A); the channel is unbuffered and has no receiver, so the goroutine parks in sendq forever. The stack, the closure, and the loop variable are pinned for the process lifetime.
The fix — give the producer a way to learn the caller is done, and make the producer select on it:
func numbers(done <-chan struct{}) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for i := 0; ; i++ {
select {
case ch <- i: // proceed only if a receiver is ready
case <-done: // caller signalled done → exit cleanly
return
}
}
}()
return ch
}
func main() {
done := make(chan struct{})
defer close(done) // guarantees the producer is told to stop
for n := range numbers(done) {
if n == 3 {
break
}
}
}Line by line: the producer’s send is now one case of a select; the other case receives from done. When the caller’s defer close(done) fires, the <-done case becomes immediately actionable, the producer returns, defer close(ch) runs, and the goroutine ends. The send case and the cancellation case are raced on every iteration, so the producer can never get stuck on the send — there is always a live escape. In modern code done <-chan struct{} is replaced by ctx context.Context and the case becomes case <-ctx.Done() (Go Blog: Context).
Shape 2 — abandoned select leak
// LEAKY: goroutine waits for a result that the cancelled caller will never collect.
func fetch(ctx context.Context, url string) string {
resultCh := make(chan string) // unbuffered, NO buffer
go func() {
resultCh <- httpGet(url) // (B) blocks until someone receives
}()
select {
case r := <-resultCh:
return r
case <-ctx.Done():
return "" // caller leaves on cancellation...
}
// ...but the goroutine at (B) is now sending into a channel
// that this function will never receive from again → leaked.
}When ctx is cancelled, fetch returns via the <-ctx.Done() case. Meanwhile the spawned goroutine is parked at (B) trying to send the HTTP result; fetch has returned and will never execute <-resultCh again, and the channel is unbuffered, so the send never completes. Every cancelled fetch leaks one goroutine.
The fix is a buffer of one:
resultCh := make(chan string, 1) // buffered: capacity exactly 1Now the send at (B) always completes — it deposits the value into the one-slot buffer and the goroutine returns, whether or not anyone ever receives. The unread value is simply garbage-collected with the channel. The buffer size of 1 is a deliberate correctness argument: “the goroutine sends exactly once and must never block.” This is the canonical fix for the abandoned-select leak and appears throughout the standard library.
time.Afteris no longer the textbook exampleOlder write-ups cite
time.Afteras a stdlib instance of this buffer-of-one trick, and pre-Go-1.23 it was one: its channel had capacity 1 so the runtime’s timer goroutine could deliver the tick without a receiver. As of Go 1.23 (for modules declaringgo 1.23.0+) the timer channel is unbuffered (capacity 0); the delivery mechanism changed so a stale tick is never left in a buffer, fixing long-standingReset/Stopraces (Go 1.23 release notes). Sotime.Afteris now a poor illustration of the buffered-channel fix. The buffer-of-one pattern itself remains correct and idiomatic for your own one-shot result goroutines; just do not point attime.Afteras the canonical example anymore. The old behaviour can be restored withGODEBUG=asynctimerchan=1.
Failure Modes and Common Misunderstandings
“context cancellation kills the goroutine.” It does not. Cancelling a context only closes ctx.Done(). A goroutine leaks unless it actually selects on ctx.Done() and returns when it fires. A context passed to a goroutine that never checks it provides zero leak protection.
“The goroutine will be GC’d when it’s blocked.” Never. A blocked goroutine is reachable from the runtime’s scheduler structures and from the channel’s wait queue; the GC treats it and everything it references as live. Goroutines are not garbage; only a returning or process-ending goroutine goes away.
“Adding a buffer fixes leaks.” It fixes the one-shot leak (Shape 2) where the goroutine sends a known, fixed number of times. It does not fix a producer that loops sending unboundedly — a buffer of any finite size eventually fills and the producer blocks again. For unbounded producers you need cancellation (Shape 1’s fix), not buffering.
The leak is silent. No panic, no error, no log. The only signals are a rising runtime.NumGoroutine() and a rising heap. By the time it is an OOM, the leak has been running for hours.
Range over a channel with no close. for v := range ch blocks forever once the channel is drained if the channel is never closed. The receiving goroutine leaks. Every range-able channel needs a single, well-defined closer.
Detecting it
runtime.NumGoroutine() exported as a metric, watched for monotonic growth, is the cheapest early warning. The /debug/pprof/goroutine profile dumps every goroutine’s stack — a leak shows up as many goroutines stuck on the same chansend/chanrecv line. In tests, the go.uber.org/goleak library asserts no goroutines outlived the test.
As of Go 1.26 (released February 2026) the runtime adds an experimental goroutine-leak profiler, built at compile time with GOEXPERIMENT=goroutineleakprofile and exposed as the goroutineleak profile in runtime/pprof (and the /debug/pprof/goroutineleak HTTP endpoint via net/http/pprof). Collecting it triggers a special stop-the-world GC cycle that uses reachability analysis to mark goroutines that are blocked on a concurrency primitive no runnable goroutine can ever reach — exactly the blocked-channel leaks in this note. The blocked-send shapes here (chan send, chan receive, select) are precisely the wait-reasons that put a goroutine into the detector’s “candidate-leaked” partition, so a clean blocked-channel leak — where the channel is only referenced from the dead goroutine’s own stack — is reliably caught. The known blind spot: if the channel is also held by a package-level global or by a runnable goroutine’s local, the detector conservatively treats it as reachable and stays silent (Go 1.26 release notes). The mechanism, seed-set, and limitations are covered in full in Goroutine Leaks; see also Go 1.26 Release Notes.
Alternatives and When to Choose Them
context.Contextpropagation — the standard, idiomatic mechanism. Every goroutine that may outlive its caller takes actxandselects onctx.Done(). Choose this for request-scoped goroutines (handlers, RPC fan-outs).- A
done chan struct{}closed by the owner — the older, lower-level form of the same idea; still appropriate when there is no request scope and nocontext. Closing (not sending) means every watcher is woken. - Buffered channel of capacity = number of sends — the fix for one-shot result goroutines (Shape 2). Use only when the send count is fixed and known.
errgroup.Group— for a group of goroutines that should all be cancelled when any fails or when the parent returns; it bundles acontextand aWaitGroupand makes “wait for all, propagate the first error” the default. See errgroup and Structured Concurrency.- Explicit
WaitGroupjoin before return — ensures a function does not return until its goroutines have finished, converting a potential leak into a (visible) block you can then debug.
Production Notes
The Go Blog’s “Go Concurrency Patterns: Pipelines and cancellation” is the canonical treatment: it builds a pipeline, shows the leak that early consumer exit causes, and introduces the explicit done channel as the cure — the direct ancestor of today’s context-based pattern. The recurring real-world incident is a request handler that starts a helper goroutine to do work in parallel, then returns early on client disconnect or deadline; the helper, sending its result on an unbuffered channel, leaks once per affected request. Under steady traffic with even a small fraction of cancelled requests, this is a textbook slow OOM. The two-line fixes — buffer the result channel, or select on ctx.Done() — are cheap; finding the leak after the fact, via a goroutine profile showing thousands of identical stuck stacks, is the expensive part. The discipline that prevents it: every goroutine you start must have a clearly identified, guaranteed-to-happen way to finish.
See Also
- Goroutine Leaks — the general phenomenon, all leak sources, and detection tooling (this note is the focused channel instance)
- Buffered vs Unbuffered Channel Mistakes — why the unbuffered send has nowhere to go
- The select Statement — selecting on a cancellation channel as the escape hatch
- The Context Package —
ctx.Done()and cancellation propagation - Channels in Go — channel send/receive/close semantics
- errgroup and Structured Concurrency — bounding goroutine lifetimes as a group
- Goroutine — what a goroutine costs while parked
- Go 1.26 Release Notes — the experimental goroutine-leak profiler
- Go Internals MOC — parent map, §13 Common Gotchas