Go / Gin

Go Goroutine Leak, How to Detect and Fix

A goroutine leak happens when a goroutine gets stuck permanently — usually blocked waiting on a channel operation that will never complete — and never terminates, quietly consuming memory and, over time, degrading application performance until it's restarted.

The Problem

Your application's memory usage grows steadily over time without an obvious cause, and profiling reveals a large, ever-increasing number of goroutines that never seems to decrease:

runtime.NumGoroutine() // returns a number that keeps climbing over time

Unlike a crash or panic, this often goes unnoticed for a long time, since the application keeps running — just progressively slower and using more memory.

Blocked send, no receiver goroutine ch <- result receiver already gone blocked forever

Why It Happens

Goroutines don't get garbage collected just because nothing references them anymore — they only terminate when their function returns. Common patterns that cause a goroutine to block forever, never returning:

  • A goroutine sends a value on an unbuffered channel, but the intended receiver already returned or was never listening, so the send blocks indefinitely with nothing to unblock it
  • A goroutine waits to receive from a channel that will never have anything sent to it, often because the sending side hit an early return or panic that skipped the send
  • A goroutine performing a long-running operation (like an HTTP request or database query) has no timeout or cancellation mechanism, so if the operation itself hangs, the goroutine hangs with it indefinitely
  • A for loop reading from a channel that's never closed, with no other exit condition, keeps the goroutine alive waiting for values that stop coming

The Fix

The most reliable general fix is using context.Context with a timeout or cancellation for any goroutine doing work that could potentially hang, ensuring it always has a way to terminate even if the expected happy path never occurs:

func worker(ctx context.Context, resultCh chan<- string) {
    select {
    case resultCh <- doWork():
        // sent successfully
    case <-ctx.Done():
        // context canceled or timed out - goroutine exits cleanly
        return
    }
}

Using select with both the actual channel operation and ctx.Done() ensures the goroutine has an escape hatch, rather than blocking on the channel send with no alternative path.

For the case of a receiver goroutine that might exit before a sender expects it to, prefer buffered channels sized appropriately for the expected number of sends, so a send can complete even if nothing is actively receiving at that exact moment:

resultCh := make(chan string, 1) // buffered - send won't block waiting for a receiver

Always set explicit timeouts on external operations like HTTP requests or database queries, rather than relying on the operation itself to eventually fail — an operation with no timeout can hang indefinitely if the remote side never responds:

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)

Make sure any channel a loop reads from is actually closed by the sender once no more values are coming, and use the two-value receive form to detect closure cleanly:

for {
    val, ok := <-ch
    if !ok {
        return // channel closed, exit the loop and let the goroutine terminate
    }
    process(val)
}

Still Not Working?

To confirm and diagnose an actual leak rather than guessing, use Go's built-in pprof tooling to inspect exactly which goroutines are running and where they're stuck:

import _ "net/http/pprof"
// then visit http://localhost:6060/debug/pprof/goroutine?debug=2

This dumps the full stack trace of every currently running goroutine, including exactly which line each one is blocked on — this is by far the fastest way to identify the specific channel operation or function call responsible for the leak, rather than reasoning about it purely from application-level symptoms like memory growth.