Skip to content
gohacks.dev
Concurrency

Goroutine Leaks in Go and How to Find Them in a Service

What a goroutine leak is in Go, five patterns that cause most of them, how to find them with NumGoroutine and pprof, and how to fix them.

12 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

A leaked goroutine does not crash anything. It costs a few kilobytes of stack plus whatever it points to, and it will sit there until the process exits. One leak is invisible. A leak per request in a service handling a thousand requests a second is a million goroutines in a quarter of an hour, a memory graph that climbs until the container is killed and, usually, a post-mortem that begins "it ran fine for three days".

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This guide defines the problem, shows the five patterns that cause nearly all leaks with a demonstration of each, then covers how to find leaks in a running service and in tests. It follows goroutines explained and leads to goleak, counting goroutines and stopping goroutines. Everything ran on Go 1.25.6.

What a leak is

A goroutine ends when its function returns. It can fail to return in only two ways: it loops forever doing work nobody wants, or it blocks on an operation that will never complete. Both are leaks, and the second is the common one, because blocking is what goroutines do when they wait for each other. The Go runtime does not collect goroutines, the way it collects memory, because it cannot know that nothing will ever unblock a given one. A goroutine blocked on a channel that no other goroutine references could in principle be collected, but Go does not do that, so such a goroutine stays for the life of the process.

The cost has two parts. The goroutine's own stack, 2 KB at least (what a goroutine costs measured about 2.0 KB each). And everything reachable from its stack: a buffer, a request, a database connection, a large slice. That second part is usually the bigger one, and it explains why a leak of ten thousand goroutines can hold gigabytes.

The five patterns

1. Sending with nobody to receive

The producer sends a result; the consumer has already given up (timeout, error, early return):

main.goGo
package main

import (
	"fmt"
	"runtime"
	"time"
)

func slow() int {
	time.Sleep(30 * time.Millisecond)
	return 42
}

func query() (int, bool) {
	result := make(chan int) // unbuffered: the send blocks until someone receives
	go func() { result <- slow() }()

	select {
	case v := <-result:
		return v, true
	case <-time.After(10 * time.Millisecond):
		return 0, false // gives up; the goroutine is now stuck on the send
	}
}

func main() {
	before := runtime.NumGoroutine()
	for range 5 {
		query()
	}
	time.Sleep(100 * time.Millisecond)
	fmt.Println("goroutines leaked:", runtime.NumGoroutine()-before)
}
Text
goroutines leaked: 5

Each timed-out call leaves its goroutine blocked forever on result <- slow(). After five calls, five goroutines are stuck. The fix is a buffer of one, so the send completes even if nobody receives: make(chan int, 1) (buffered channels explains the reasoning). The more general fix is to give the goroutine a context and have the send select on ctx.Done().

2. Receiving from a channel that nobody sends on or closes

A consumer ranges over a channel; the producer finishes or fails without closing it:

main.goGo
package main

import (
	"fmt"
	"runtime"
	"time"
)

func consume(in <-chan int, done chan<- struct{}) {
	for range in { // waits for the channel to close
	}
	close(done)
}

func main() {
	before := runtime.NumGoroutine()
	for range 5 {
		ch := make(chan int)
		done := make(chan struct{})
		go consume(ch, done)
		ch <- 1
		// the producer is finished but never closes ch
	}
	time.Sleep(50 * time.Millisecond)
	fmt.Println("goroutines leaked:", runtime.NumGoroutine()-before)
}
Text
goroutines leaked: 5

Each consumer waits for a close that never comes. The cure is defer close(ch) in the producer (who closes).

3. No way to stop a loop

A goroutine with a for { ... } loop that waits on a timer or a channel and has no exit condition is a leak as soon as its owner goes away:

main.goGo
package main

import (
	"fmt"
	"runtime"
	"time"
)

type Poller struct{ stop chan struct{} }

func startBad() {
	go func() {
		for {
			time.Sleep(5 * time.Millisecond) // polls forever
		}
	}()
}

func startGood() *Poller {
	p := &Poller{stop: make(chan struct{})}
	go func() {
		t := time.NewTicker(5 * time.Millisecond)
		defer t.Stop()
		for {
			select {
			case <-t.C:
			case <-p.stop:
				return
			}
		}
	}()
	return p
}

func main() {
	before := runtime.NumGoroutine()
	for range 3 {
		startBad() // no handle, no way to stop it
	}
	for range 3 {
		close(startGood().stop)
	}
	time.Sleep(50 * time.Millisecond)
	fmt.Println("goroutines left running:", runtime.NumGoroutine()-before)
}
Text
goroutines left running: 3

The three "bad" pollers are still running; the three "good" ones were told to stop and exited. A component that starts a goroutine must expose a way to end it (a Close, a context parameter) and document that callers must use it.

4. A response body that is never closed

An HTTP client leaks goroutines and connections when the response body is not read and closed. The transport's goroutines for that connection wait for the body to be consumed:

Go
resp, err := http.Get(url)
if err != nil {
	return err
}
// missing: defer resp.Body.Close()

Always defer resp.Body.Close() after checking the error, and read the body (or io.Copy(io.Discard, resp.Body)) if you want the connection reused. This is the most common leak in Go services, because the code looks right and nothing fails.

5. A stuck lock or a wait that never ends

A goroutine blocked on a sync.Mutex held by something that never releases it, or in wg.Wait() for a counter that never reaches zero, is a leak that the deadlock detector will not report while other goroutines are running (the deadlock page). The same goes for a select {} or a receive on a nil channel (nil channels).

Finding leaks in a running service

Watch the count

runtime.NumGoroutine() is the first signal. Export it as a metric. A healthy service's goroutine count rises and falls with load; a leaking one rises with each request and never comes back down. A graph that is a staircase or a slow ramp, on an otherwise idle service, is a leak. Alert on growth, not on an absolute number.

Read the goroutine profile

Once you know there is a leak, ask which goroutines are stuck. The pprof goroutine profile groups goroutines by identical stack and gives a count for each, so a leak shows up as a stack with a large number:

main.goGo
package main

import (
	"fmt"
	"os"
	"runtime/pprof"
	"time"
)

func leaky() {
	ch := make(chan int)
	go func() { ch <- 1 }() // no receiver: leaks
}

func main() {
	for range 25 {
		leaky()
	}
	time.Sleep(20 * time.Millisecond)

	fmt.Println("goroutine profile, grouped by stack:")
	pprof.Lookup("goroutine").WriteTo(os.Stdout, 1)
}
Text
goroutine profile, grouped by stack:
goroutine profile: total 26
25 @ 0x10263b990 0x1025d9c84 0x1025d98d8 0x102692628 0x102642984
#	0x102692627	main.leaky.func1+0x27	example.test/lab/main.go:12

1 @ 0x10260271c 0x10263ad64 0x102684514 0x102684340 0x102681ab4 0x1026926e0 0x10260bbd8 0x102642984
#	0x102684513	runtime/pprof.writeRuntimeProfile+0xb3	runtime/pprof/pprof.go:788
#	0x10268433f	runtime/pprof.writeGoroutine+0x4f	runtime/pprof/pprof.go:747
#	0x102681ab3	runtime/pprof.(*Profile).WriteTo+0x143	runtime/pprof/pprof.go:371
#	0x1026926df	main.main+0x9f				example.test/lab/main.go:22
#	0x10260bbd7	runtime.main+0x277			runtime/proc.go:285

The first line of the profile reports the total and each block reports a count, 25 @ ..., followed by the stack, ending in the file and line of the blocked send. That is the line to fix. In a service, import net/http/pprof and fetch /debug/pprof/goroutine?debug=1 (grouped) or ?debug=2 (every goroutine with its state and how long it has been blocked). The debug=2 form shows durations such as [chan send, 12 minutes], which separate a leak from a busy-but-healthy goroutine (goroutine dumps).

Catch them in tests

A leak that a test could have caught is one you debug in production. The cheap version compares the goroutine count before and after, as the demos above do, with a short wait for goroutines to finish. A better version uses a library that retries until the count stabilises and prints the stacks of the survivors (detecting leaks with goleak). Put the check in TestMain for the whole package so any test that leaks fails.

Fixing leaks: the principles

  1. Every goroutine has an exit. Before writing go, answer: what ends it? A context, a closed channel, a finite loop.
  2. Every blocking operation has an escape. select with ctx.Done(), or a buffered channel that cannot block.
  3. The creator owns the shutdown. The code that starts a goroutine is responsible for stopping it and for waiting until it has stopped (WaitGroup, errgroup) (starting and stopping goroutines in order).
  4. Close what you open. Response bodies, files, tickers (Stop), contexts (cancel).
  5. Bound concurrency. A worker pool of fixed size cannot leak a goroutine per request, even if a task hangs; it can only hang a worker, which a per-task timeout then recovers (worker pools).

What a leak looks like in memory

The goroutine is the small part. Here is a leak that holds a large buffer, and the heap it pins:

main.goGo
package main

import (
	"fmt"
	"runtime"
	"time"
)

var sink byte

func leakWithBuffer() {
	buf := make([]byte, 1<<20) // 1 MiB the goroutine keeps alive
	ch := make(chan struct{})
	go func() {
		<-ch // never released
		sink = buf[len(buf)-1]
	}()
}

func main() {
	var before, after runtime.MemStats
	runtime.GC()
	runtime.ReadMemStats(&before)

	for range 100 {
		leakWithBuffer()
	}
	time.Sleep(20 * time.Millisecond)
	runtime.GC()
	runtime.ReadMemStats(&after)

	fmt.Printf("leaked goroutines: %d\n", runtime.NumGoroutine()-1)
	fmt.Printf("heap held by them: about %d MiB\n", (int64(after.HeapAlloc)-int64(before.HeapAlloc))>>20)
}
Text
leaked goroutines: 100
heap held by them: about 100 MiB

A hundred leaked goroutines pin a hundred megabytes, because each one's stack references its megabyte buffer, so the garbage collector cannot free it even after a forced collection. The leak is the goroutine count, and the damage is the memory behind each, which is why a service with a modest goroutine leak can still be killed for exceeding its memory limit.

Case study: the unclosed response body

Pattern 4 deserves a demonstration, since it needs no concurrency code of your own to bite. Each HTTP/1.1 connection in Go's transport has two goroutines, a reader and a writer. When a response body is not read to the end and closed, the connection cannot be reused or released, and a new request opens another one:

main.goGo
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"runtime"
	"time"
)

func main() {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "some response body")
	}))
	defer srv.Close()

	count := func(closeBody bool) int {
		tr := &http.Transport{}
		client := &http.Client{Transport: tr}
		defer tr.CloseIdleConnections()

		before := runtime.NumGoroutine()
		for range 10 {
			resp, err := client.Get(srv.URL)
			if err != nil {
				continue
			}
			if closeBody {
				resp.Body.Close()
			} // else: the body is left open and unread
		}
		time.Sleep(50 * time.Millisecond)
		return runtime.NumGoroutine() - before
	}

	fmt.Println("extra goroutines when bodies are left open:", count(false) > 2)
	fmt.Println("extra goroutines when bodies are closed:   ", count(true))
}
Text
extra goroutines when bodies are left open: true
extra goroutines when bodies are closed:    0

With bodies left open, each request holds a connection of its own, and the transport's reader and writer goroutines for each stay alive: the printed comparison shows extra goroutines accumulating. With bodies closed, nothing accumulates, and the count returns to where it started. Closing a body that was not read to the end makes the transport discard the connection instead of returning it to the idle pool, which is why no goroutines remain here; to get connection reuse (and save the cost of a new handshake on the next request) you also need to read the body to the end before closing. In a real service the leaking pattern appears as a slow climb in goroutines, file descriptors and memory, and it ends in too many open files or an out-of-memory kill. The fix is mechanical: defer resp.Body.Close() immediately after the error check, on every response, including ones you do not read; and drain the rest of the body with io.Copy(io.Discard, resp.Body) before closing when you want the connection reused. Both steps belong in the one helper that your code uses for outbound calls, so no caller can forget them.

Worker leaks: when the pool outlives its work

Pools and consumers leak differently from request-scoped goroutines. A worker pool whose input channel is never closed keeps its workers forever; a program that creates a pool per request without shutting it down has one pool's worth of leaked goroutines per request. The test is symmetrical to the construction: for every NewPool there must be a Close or a context, and the code that creates it must call it. Make it hard to forget by returning the closer from the constructor, by using a context passed to New, or by building pools once at start-up and sharing them. A pool that lives as long as the process is not a leak, because it is bounded and owned; the failure is the pool created per call.

Leaks that are not goroutines

The same shape of bug shows up in other resources, and it is worth checking them together. A ticker or timer that is not stopped keeps a runtime timer alive (and, before Go 1.23, kept its channel alive too) (tickers). A context created with WithCancel or WithTimeout and never cancelled holds a registration in its parent. A file or connection that is not closed holds a descriptor, and descriptors run out long before memory does, with the unmistakable error too many open files. A map or cache that only grows is a leak with no goroutine at all. When a service's memory climbs, count goroutines, open file descriptors and heap objects, because the three often move together and the one that moves first points at the cause.

Preventing leaks by design

The patterns above share a root: a goroutine was started without anyone being responsible for ending it. Design rules that make that hard:

  • Constructors that start goroutines return a stop function or implement Close. A type that launches a background loop in New and has no Close cannot be used correctly in a test, and cannot be shut down in production.
  • Pass a context to anything long-lived. The caller's context is the lifetime. When it ends, the goroutines end.
  • Prefer synchronous APIs. A function that blocks until its work is done and returns a value is far harder to leak than one that starts a goroutine and returns a channel. Let the caller add concurrency, because the caller knows what it will do with it.
  • Use errgroup or a WaitGroup for fan-out, so the function that starts the goroutines is the function that waits for them, and the lifetime of the goroutines is contained in the lifetime of the call.
  • Tests check for leaks by default. A TestMain that fails when goroutines remain makes a leak a failing build, not a production incident.

These are habits, and they remove most of the patterns above before they are written. For the rest, the profile tells you exactly where the stuck goroutines are waiting, and reading that one line is nearly always enough to see the missing exit.

Closing the loop

Finding a leak once is not enough; the point is to keep the count honest over time. Export the goroutine count and the open-descriptor count as metrics, alert on sustained growth, and keep a leak check in the test suite of every package that starts goroutines. Treat a leak report like a failing test: reproduce it with the smallest program that shows the count rising, fix the missing exit, and keep the reproduction as a regression test.

A checklist for review

When reviewing code that starts goroutines, ask: what stops this goroutine, and who calls it? What happens to a send if the receiver leaves? Is every channel that is ranged over closed on every path? Are response bodies closed on every path including errors? Is there a test that fails if the goroutine remains? The references are the runtime/pprof documentation for the profile, the net/http/pprof package for serving it, and the Go blog's Profiling Go Programs.

Also inTooling
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.