Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 064 / 100

Avoiding goroutine leaks: a hands-on guide for engineers

Every goroutine needs an exit.

Expona Admin7 min read

Opening the problem

Goroutines are one of Go's most powerful features, but they come with a hidden cost: if you're not careful, they'll keep running long after you thought they'd finished. Avoiding goroutine leaks is a hands-on guide for engineers who've shipped production systems and learned the hard way that every goroutine needs an exit. We're going to walk through the patterns that cause leaks, the mechanisms that prevent them, and the testing strategies that catch them before they reach production.

Blocked sends on unread channels leak

One of the most insidious goroutine leaks happens when a goroutine tries to send a value on a channel that no one is reading from. The goroutine blocks forever, waiting for a receiver that will never come. This is particularly dangerous because the goroutine doesn't panic or log an error—it simply hangs silently.

Consider a worker pool pattern where you spawn multiple goroutines to process jobs from a channel:

go
func processJobs(jobs <-chan int) {
    for job := range jobs {
        fmt.Println("Processing job:", job)
        time.Sleep(1 * time.Second)
    }
}

func main() {
    jobs := make(chan int)
    
    // Start 5 workers
    for i := 0; i < 5; i++ {
        go processJobs(jobs)
    }
    
    // Send one job, then abandon the channel
    jobs <- 1
    // BUG: jobs channel is never closed, workers block forever
}

In this example, the five goroutines are blocked inside the for range jobs loop, waiting for more jobs to arrive. Since we never close the channel, they never exit. Real-world scenarios often look like this: a handler function starts a goroutine to send data somewhere, but the request context is cancelled before that send completes. The goroutine blocks on the send and never recovers.

The fix is to always close the channel when you're done sending:

go
func main() {
    jobs := make(chan int)
    
    // Start 5 workers
    for i := 0; i < 5; i++ {
        go processJobs(jobs)
    }
    
    jobs <- 1
    close(jobs) // Signal workers to exit
    
    // Wait for workers to finish
    time.Sleep(2 * time.Second)
}

When the channel is closed, all receivers unblock and the loop terminates. The goroutines can then exit gracefully.

Always handle ctx.Done()

Context cancellation is the Go team's recommended pattern for signaling goroutines to stop. Every long-running goroutine should listen for ctx.Done() and exit when it fires. This is especially critical in HTTP handlers, where the request context is cancelled when the client disconnects or the server shuts down.

Here's a common pattern: a handler spawns a background task that should inherit the request's context:

go
func handleRequest(w http.ResponseWriter, r *http.Request) {
    // BUG: goroutine doesn't listen for context cancellation
    go func() {
        for {
            result := expensiveOperation()
            // Send to external service
            sendToService(result)
            time.Sleep(5 * time.Second)
        }
    }()
    
    w.WriteHeader(http.StatusAccepted)
}

If the client disconnects or the request times out, this goroutine keeps running forever. The correct approach is to pass the request context and select on ctx.Done():

go
func handleRequest(w http.ResponseWriter, r *http.Request) {
    go func(ctx context.Context) {
        ticker := time.NewTicker(5 * time.Second)
        defer ticker.Stop()
        
        for {
            select {
            case <-ctx.Done():
                // Request cancelled or timed out; exit cleanly
                return
            case <-ticker.C:
                result := expensiveOperation()
                sendToService(result)
            }
        }
    }(r.Context())
    
    w.WriteHeader(http.StatusAccepted)
}

By always selecting on ctx.Done(), we ensure the goroutine exits when the context is cancelled. This pattern scales to multiple goroutines and integrates cleanly with HTTP server shutdown.

Close channels from the sender

Go has a strict rule: only the sender should close a channel. If a receiver closes a channel or multiple senders close the same channel, the program will panic. This rule exists to prevent race conditions, but it means we need clear ownership of channel lifecycle.

A common mistake in team environments is unclear responsibility:

go
func producer(results chan<- int) {
    for i := 0; i < 10; i++ {
        results <- i
    }
    // Sender responsibility: close the channel
    close(results)
}

func consumer(results <-chan int) {
    for val := range results {
        fmt.Println(val)
    }
    // Receiver never closes; only reads
}

Notice the asymmetry: producer uses a send-only channel (chan<- int) and closes it. consumer uses a receive-only channel (<-chan int) and never closes. This makes the ownership explicit and prevents accidents.

In real systems, this pattern prevents bugs like this:

go
// BAD: unclear who owns the channel
func startWorker(work chan Job) {
    go func() {
        for job := range work {
            process(job)
        }
        close(work) // Might panic if other senders exist
    }()
}

If multiple goroutines are sending on work, closing it inside one of them will panic when another goroutine tries to send. The fix is to have a single lifecycle manager:

go
// GOOD: clear ownership
func startWorkers(work chan<- Job, numWorkers int) {
    var wg sync.WaitGroup
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range work {
                process(job)
            }
        }()
    }
    // Only the caller closes
    go func() {
        wg.Wait()
        close(work)
    }()
}

The caller sends on work and closes it after all workers have exited (signalled by sync.WaitGroup). Workers only receive and never close. This makes the contract clear.

Use goleak in tests

Testing is your last line of defense against goroutine leaks. The goleak package detects goroutines that are still running after your test completes. It's not perfect—some false positives exist—but it catches most common leaks.

Add goleak to your test suite:

go
import "github.com/uber-go/goleak"

func TestProcessJobs(t *testing.T) {
    defer goleak.VerifyNone(t)
    
    jobs := make(chan int)
    go processJobs(jobs)
    
    jobs <- 1
    close(jobs)
    
    time.Sleep(100 * time.Millisecond)
}

If a goroutine is still running when the test exits, goleak will fail with a detailed stack trace showing which goroutine leaked. This catches leaks that would otherwise silently accumulate in production.

For HTTP handlers, integrate goleak into your integration tests:

go
func TestHandleRequestCleanup(t *testing.T) {
    defer goleak.VerifyNone(t)
    
    server := httptest.NewServer(http.HandlerFunc(handleRequest))
    defer server.Close()
    
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel()
    
    req, _ := http.NewRequestWithContext(ctx, "GET", server.URL, nil)
    http.DefaultClient.Do(req)
    
    // Test finishes; goleak verifies no goroutines remain
}

The context timeout ensures the request is cancelled while goroutines are still running. Goleak then verifies they cleaned up properly.

Common mistakes

  • Fire-and-forget goroutines without cancellation: Launching a goroutine without a way to signal it to stop is a guaranteed leak in long-running services. Always pass context or a cancel channel.
  • Forgetting to close channels in unit tests: Tests often create channels and launch goroutines, then exit without cleanup. Use defer close(ch) or ensure all receivers exit before the test ends.
  • Mixing buffered and unbuffered channels carelessly: A buffered channel can hide blocking sends temporarily. A goroutine sending on a full buffered channel still blocks. Don't rely on buffer size as a leak prevention mechanism.
  • Ignoring context propagation in middleware: Each layer of your application should accept and respect context. A middleware that spawns goroutines without passing the request context is a leak waiting to happen.
  • Testing only the happy path: Goroutine leaks often appear under error conditions or when requests are cancelled. Write tests that simulate client disconnects and timeouts.

Key takeaways

  • Every goroutine needs a clear exit condition: a closed channel, a cancelled context, or a done signal.
  • Blocked sends on unread channels are silent and deadly. Always close channels from the sender side.
  • Context cancellation (ctx.Done()) is the idiomatic way to signal goroutines in HTTP services. Use it in every long-running goroutine.
  • Channel ownership matters: senders close, receivers don't. Enforce this with send-only and receive-only channel types.
  • Add goleak to your test suite and run it regularly. It catches leaks that code review might miss.
  • Goroutine leaks accumulate over time and consume resources silently. In production, they can crash your service under load.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles