Go's Goroutine Leak from Unbuffered Channel: The Senders That Wait Forever After You've Given Up

2026-08-20

This function fans out URL fetches across goroutines and gathers the results, respecting a caller-provided context. It looks like textbook Go concurrency — a select on both the results channel and ctx.Done(), an early return on cancellation, no shared mutable state. Ship it?

func FetchAll(ctx context.Context, urls []string) ([]Result, error) {
    results := make(chan Result)

    for _, url := range urls {
        go func(u string) {
            results <- fetchOne(u) // synchronous HTTP call
        }(url)
    }

    var out []Result
    for i := 0; i < len(urls); i++ {
        select {
        case r := <-results:
            out = append(out, r)
        case <-ctx.Done():
            return out, ctx.Err() // caller cancelled or timed out
        }
    }
    return out, nil
}

Under load tests the function passes. In production, memory climbs, connection pools stay pinned open, and runtime.NumGoroutine() creeps upward for hours after any traffic spike.

The Bug

The channel is unbuffered. An unbuffered send in Go is a rendezvous — results <- fetchOne(u) blocks until some other goroutine executes a matching receive. As long as the collector loop keeps reading, everything works. But the moment ctx.Done() fires, the collector returns.

The pending goroutines don't know that. They finish their HTTP calls, race to the results <- statement, and block there forever. Nobody will ever receive. The Go runtime has no way to prove the channel is unreachable — the goroutines still hold a reference to results, so it isn't garbage. They're not deadlocked in a way runtime can detect either; they're just waiting, indistinguishable from a slow but healthy worker.

Each leaked goroutine holds its stack (a few KB), any captured closures, the response body it fetched, and — if fetchOne returns before closing — its underlying TCP connection. Ten cancellations per second is 36,000 zombie goroutines per hour, all invisible to your metrics unless you're specifically watching goroutine count.

The trap is that the code looks like it handles cancellation. It handles cancellation of the caller. It does nothing about the fan-out it launched.

The Fix

Buffer the channel to len(urls). Every send now has a guaranteed slot; no sender ever blocks; goroutines complete and are collected even if nobody reads:

func FetchAll(ctx context.Context, urls []string) ([]Result, error) {
    results := make(chan Result, len(urls)) // one slot per sender

    for _, url := range urls {
        go func(u string) {
            results <- fetchOne(u) // never blocks
        }(url)
    }

    var out []Result
    for i := 0; i < len(urls); i++ {
        select {
        case r := <-results:
            out = append(out, r)
        case <-ctx.Done():
            return out, ctx.Err()
        }
    }
    return out, nil
}

A stronger fix also passes ctx down to fetchOne so the in-flight HTTP requests actually abort instead of just being ignored — otherwise you've stopped leaking goroutines but you're still burning network for results nobody wants.

The rule of thumb: if a goroutine sends on a channel, and any code path can stop receiving from that channel before all sends complete, the channel must be buffered — or the sender must select on a cancellation signal. "The receiver always runs to completion" is a promise you should never make implicitly.

Key Takeaway: An unbuffered send is a two-party contract; if the receiver can walk away early, every unfulfilled sender leaks forever, invisibly holding memory and connections until the process dies.

All newsletters