Goroutine Leak: Silent Memory Eater in Golang

1. Overview

Goroutines are what make Go “cool” — lightweight, easy to spawn, just a few KB each. But because they’re so lightweight, you easily create them and forget to… clean up. A goroutine leak is when a goroutine gets blocked permanently, never finishes, and accumulates over time until RAM runs out or the system crashes.

After reading this, you’ll know: the most common leak-causing patterns, how to use pprof and runtime metrics to detect them, and how to fix using context, buffered channels, and select with timeout.

2. What is a goroutine leak

Unlike typical memory leaks (objects not garbage-collected because references still exist), a goroutine leak is when a goroutine gets blocked permanently at some point — typically waiting to read/write a channel that never happens. That goroutine doesn’t die, doesn’t get garbage-collected, and its stack (along with any closure variables it holds) exists permanently in the heap.

The most dangerous issue: the program doesn’t crash immediately. It runs normally, requests still return correct results, but RSS (resident memory) grows gradually with each request. After a few hours or days, the container gets OOMKilled.

3. Common leak pattern #1: Sending to a channel with no receiver

func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
	resultCh := make(chan string)

	go func() {
		result := slowHTTPCall(url) // assume takes 5s
		resultCh <- result          // (1) send to unbuffered channel
	}()

	select {
	case res := <-resultCh:
		return res, nil
	case <-time.After(timeout):
		return "", fmt.Errorf("timeout after %v", timeout)
	}
}

If timeout (say 1s) is shorter than the time slowHTTPCall takes (5s), the fetchWithTimeout function returns a timeout error and exits at the case <-time.After branch. But the child goroutine at line (1) is still running. When it finishes after 5s, it tries to send result into resultCh — but no one is reading this channel anymore because the parent function has returned. Since resultCh has no buffer, sending to a channel with no receiver will block forever. That goroutine leaks permanently.

Call this function 1000 times/second with a short timeout, and you get 1000 leaked goroutines every second.

3.1. Fix: buffered channel

func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
	resultCh := make(chan string, 1) // (1) buffer size 1

	go func() {
		result := slowHTTPCall(url)
		resultCh <- result // never blocks because already has buffer
	}()

	select {
	case res := <-resultCh:
		return res, nil
	case <-time.After(timeout):
		return "", fmt.Errorf("timeout after %v", timeout)
	}
}

Line (1) gives the channel a buffer of 1. When the child goroutine sends result, it writes to the buffer and exits immediately — no need for anyone to read. The goroutine finishes normally, no leak. This is a quick fix but not complete: slowHTTPCall still runs for the full 5s even though the caller no longer cares about the result, wasting CPU/network.

4. Common leak pattern #2: Forgetting to cancel goroutines when context is cancelled

func worker(ch <-chan int) {
	go func() {
		for v := range ch { // (1) waits forever if ch is never closed
			process(v)
		}
	}()
}

If no one close(ch), the goroutine at line (1) waits to read from the channel forever. This pattern often occurs when a worker is created within a request scope but the source channel is tied to a different lifecycle (for example, a global queue that never closes).

4.1. Fix using context.Context

func worker(ctx context.Context, ch <-chan int) {
	go func() {
		for {
			select {
			case <-ctx.Done(): // (1) receive cancellation signal
				return
			case v, ok := <-ch:
				if !ok { // (2) channel closed
					return
				}
				process(v)
			}
		}
	}()
}

Line (1) listens to ctx.Done() — when the caller invokes cancel(), the goroutine exits immediately even if ch has nothing to read. Line (2) handles the case when the channel is closed normally. This is the standard pattern: every long-running goroutine should accept context.Context and always have an exit path via ctx.Done().

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
worker(ctx, dataCh)

5. Common leak pattern #3: sync.WaitGroup with goroutines that never call Done()

func processAll(items []Item) {
	var wg sync.WaitGroup
	for _, item := range items {
		wg.Add(1)
		go func(it Item) {
			defer wg.Done()
			if err := validate(it); err != nil {
				return // forgot to call Done() before returning? No, defer still runs
			}
			save(it)
		}(item)
	}
	wg.Wait() // (1) wait for all goroutines to call Done()
}

In this example defer wg.Done() is safe because defer always runs even on early return. But the real leak occurs when a goroutine blocks inside before it can return — for example, save(it) calls an API without timeout and hangs forever. Then wg.Wait() at line (1) also blocks indefinitely, causing the goroutine that called processAll to leak as well.

Note: defer wg.Done() only saves you from forgetting to call Done(). It doesn’t save you from a goroutine hanging inside the body. Always set a timeout for all I/O (HTTP client, DB query, gRPC call).

6. Common leak pattern #4: Leaking time.Ticker without Stop()

func startMonitor() {
	ticker := time.NewTicker(5 * time.Second)
	go func() {
		for range ticker.C { // (1) runs forever
			checkHealth()
		}
	}()
}

time.NewTicker creates an internal goroutine in the runtime to pump ticks into ticker.C. If you never call ticker.Stop(), both the reading goroutine at line (1) and the internal ticker goroutine live forever — even if startMonitor is called multiple times (for example, on each config reload), you accumulate many tickers running in parallel.

6.1. Fix: tie lifecycle to context

func startMonitor(ctx context.Context) {
	ticker := time.NewTicker(5 * time.Second)
	go func() {
		defer ticker.Stop() // (1) always clean up ticker
		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				checkHealth()
			}
		}
	}()
}

Line (1) ensures ticker.Stop() is called when the goroutine exits, no matter which path it takes. ctx.Done() allows the caller to actively stop the monitor when no longer needed.

7. Detecting goroutine leaks with pprof

The best way to detect leaks is not to read code by eye but to measure directly.

import (
	"net/http"
	_ "net/http/pprof" // (1) automatically registers debug routes
)

func main() {
	go func() {
		http.ListenAndServe("localhost:6060", nil)
	}()
	// ... rest of app
}

Line (1) imports net/http/pprof with a blank name (_) — only for the side-effect of registering the /debug/pprof/* routes, no need to call any functions from this package.

Run the app and then call:

curl http://localhost:6060/debug/pprof/goroutine?debug=1
goroutine profile: total 15234
14998 @ 0x43f1 0x454a 0x4a2b ...
#	0x4a2b	main.fetchWithTimeout.func1+0x5b	/app/main.go:12

236 @ 0x43f1 0x454c ...

The number 14998 at the top line is a clear sign: nearly 15,000 goroutines are hanging at exactly one point in the code (main.go:12). This is exactly a goroutine leak — a pattern repeating en masse, not normal traffic.

7.1. Monitor goroutine count over time

func logGoroutineCount() {
	ticker := time.NewTicker(10 * time.Second)
	for range ticker.C {
		log.Printf("goroutines: %d", runtime.NumGoroutine())
	}
}

runtime.NumGoroutine() returns the number of living goroutines. If this number keeps increasing and never decreases even with stable traffic, that’s a very reliable leak indicator. You should expose this metric via Prometheus (go_goroutines — actually the Prometheus Go client already provides this metric).

8. Writing tests to catch leaks early

The go.uber.org/goleak library helps you write tests that automatically detect leaks instead of waiting to discover them in production.

<!-- use go.mod instead of Maven -->
go get go.uber.org/goleak
package fetcher_test

import (
	"testing"
	"time"

	"go.uber.org/goleak"
)

func TestFetchWithTimeout_NoLeak(t *testing.T) {
	defer goleak.VerifyNone(t) // (1) check after test finishes

	_, err := fetchWithTimeout("http://slow-endpoint.local", 100*time.Millisecond)

	if err == nil {
		t.Fatal("expected timeout error")
	}

	time.Sleep(200 * time.Millisecond) // (2) give child goroutine time to finish
}

Line (1) registers a check to run after the test finishes: goleak compares the list of living goroutines at that point with the baseline, and if there are any “unexpected” goroutines (not from runtime/test framework), the test fails and prints the stack trace of the leaked goroutine. Line (2) gives the child goroutine enough time to finish before checking — without this line, the test could produce false positives.

If you use the unbuffered-channel version of fetchWithTimeout from section 3, this test will fail with output like:

--- FAIL: TestFetchWithTimeout_NoLeak (0.30s)
    goleak.go:39: found unexpected goroutines:
    [Goroutine 18 in state chan send, with fetcher.fetchWithTimeout.func1 on top of the stack:
    goroutine 18 [chan send]:
    fetcher.fetchWithTimeout.func1()
        /app/fetcher.go:9 +0x65
    ]

The stack trace points exactly to the leak-causing line — very useful for debugging.

9. Checklist for avoiding goroutine leaks

  • Every long-running I/O goroutine should accept context.Context and have a branch for <-ctx.Done().
  • Unbuffered channels used to send results back to a caller that might abandon them — consider buffer size 1 or use context.
  • time.Ticker, time.Timer must be Stop()’d when no longer needed.
  • sync.WaitGroup.Wait() is only safe if all child goroutines have a guaranteed exit path (timeout for I/O inside).
  • Write tests with goleak.VerifyNone(t) for functions that create goroutines, especially those with timeout/cancel logic.
  • Monitor runtime.NumGoroutine() or the go_goroutines metric on your production dashboard.

Note: Not every long-living goroutine is a leak. A worker pool that runs for the lifetime of the application is valid design. The issue only arises when the number of goroutines grows unbounded with traffic without corresponding decrease.

10. Conclusion

Goroutine leaks are dangerous because they don’t cause immediate errors — the application still returns correct results while RAM grows gradually behind the scenes. The root causes are almost always: sending to a channel with no receiver, or a goroutine lacking an exit path when the caller has given up (timeout, cancel). Prevention is simple: always pass context.Context to long-running goroutines, buffer result channels, and write tests with goleak to catch errors during code review instead of waiting for production OOMKill.

golanggoroutineconcurrencymemory-leakperformance