Go / Gin

How to Fix "Goroutine Leak Detected" in Long-Running Go Services

4 min read by DebuggedIt

Quick answer

Your Go service's memory and goroutine count climb steadily over time instead of staying stable, and profiling confirms goroutines are piling up without ever...

Your Go service's memory and goroutine count climb steadily over time instead of staying stable, and profiling confirms goroutines are piling up without ever finishing. A goroutine leak means you're starting goroutines that block forever β€” usually on a channel operation that never completes β€” so they never get garbage collected.

The Problem

You notice memory creeping up in production, or a leak detector catches it directly:

--- FAIL: TestFetchWorker (5.02s)
    goroutine_leak_test.go:14: goroutine leak detected:
    goroutine 42 [chan send]:
    main.fetchWorker(...)
        /app/worker.go:22 +0x8c
    created by main.startWorkers
        /app/worker.go:15 +0x65

Checking the live goroutine count over time via pprof makes the trend obvious:

$ curl http://localhost:6060/debug/pprof/goroutine?debug=1 | head -5
goroutine profile: total 4821
4780 @ 0x43e2d6 0x44f871 0x475e02 ...
#	0x475e02	main.fetchWorker+0x82	/app/worker.go:22

A goroutine count of thousands and climbing, with the same stack trace repeated over and over, is the clearest signal of a leak rather than legitimate concurrent work.

Blocked send on an unbuffered channel goroutine ch <- result blocked forever unbuffered chan no receiver receiver already returned/timed out Fix: buffered channel, select with ctx.Done(), or timeout select { case ch <- v: case <-ctx.Done(): return }

Why It Happens

A goroutine only exits when its function returns. If it's blocked on a channel send or receive that will never be matched, it sits in memory forever, holding onto whatever variables it closed over. Multiply that by every request or loop iteration that spawns one, and memory climbs steadily. Common root causes:

  • Sending on an unbuffered channel when nothing is listening anymore β€” for example, the receiver already returned early due to a timeout or an earlier error.
  • Missing context.Context cancellation, so a goroutine spawned per-request has no way to know the request already ended.
  • A for { } loop reading from a channel that's never closed, so the loop (and the goroutine) never terminates.
  • A worker pool that starts goroutines but never signals them to stop on shutdown.
  • An HTTP client call inside a goroutine with no timeout, hanging indefinitely on a slow or dead upstream.

The Fix

The most common fix is making channel sends respect context cancellation instead of blocking unconditionally:

func fetchWorker(ctx context.Context, ch chan<- Result) {
    result := doWork()
    select {
    case ch <- result:
    case <-ctx.Done():
        return // don't block forever if nobody's listening anymore
    }
}

Always pass a context with a deadline into goroutines that do I/O, and make sure the caller actually cancels it when done:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go fetchWorker(ctx, resultChan)

For worker pools, use a dedicated done channel or context to signal shutdown explicitly, and make sure every worker's loop actually checks it:

func worker(ctx context.Context, jobs <-chan Job) {
    for {
        select {
        case job, ok := <-jobs:
            if !ok {
                return
            }
            process(job)
        case <-ctx.Done():
            return
        }
    }
}

For outbound HTTP calls specifically, always set a client timeout so a hung upstream can't leak a goroutine indefinitely:

client := &http.Client{Timeout: 10 * time.Second}

Still Not Working?

If you're not sure where the leak is coming from, use the built-in pprof goroutine profile to see exactly which stack traces are piling up, since the repeated function name at the top of the profile output points directly at the leaking code:

go tool pprof http://localhost:6060/debug/pprof/goroutine
(pprof) top
(pprof) list main.fetchWorker

For catching leaks before they ever reach production, add goleak to your test suite β€” it fails any test that leaves goroutines running after the test completes, which surfaces leaks during CI instead of weeks later in a memory graph:

import "go.uber.org/goleak"

func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m)
}

It's also worth setting up ongoing production monitoring rather than only catching leaks in tests, since some leaks only manifest under real traffic patterns that are hard to reproduce locally. Expose the standard pprof endpoints in any long-running service and track the goroutine count over time as a metric, alerting if it grows without bound instead of stabilizing under steady load:

import _ "net/http/pprof"

go func() {
    log.Println(http.ListenAndServe("localhost:6060", nil))
}()

A healthy service under steady traffic should show a goroutine count that fluctuates within a stable range as requests come and go β€” not a number that climbs steadily hour over hour. If you export this as a Prometheus metric via runtime.NumGoroutine(), you can catch a leak forming in production well before it causes an out-of-memory crash:

prometheus.NewGaugeFunc(prometheus.GaugeOpts{
    Name: "goroutines_count",
}, func() float64 {
    return float64(runtime.NumGoroutine())
})

Catching the trend early, before memory pressure forces a restart, gives you time to profile and fix the leak on your own schedule instead of during an incident.