I Planted 10 Goroutine Leaks to Test Go 1.27’s New Leak Detector

A worker blocks on a channel nobody reads, the handler that spawned it returns, and your process now carries a goroutine that will live until the next deploy. Nothing in your logs mentions it. Do that once per request, and you have a memory graph that climbs for days before anyone opens an issue.

Go 1.27, released on August 19, shipped a runtime-level answer: a new pprof profile called goroutineleak that asks the garbage collector to prove which goroutines can never run again. It existed behind GOEXPERIMENT=goroutineleakprofile in Go 1.26; in 1.27 the experiment flag is gone, and the profile is available by default, with an HTTP endpoint at /debug/pprof/goroutineleak. The runtime does the detection work each time you collect the profile, never on a background timer

Runtime leak detection with no third-party library and no test harness is a bold claim. I also have a personal stake: a few months ago, I built my own live goroutine leak detector because the stable runtime tooling didn’t offer this (the profile existed then, but only if you built with the Go 1.26 experiment flag). So, I wrote ten small programs, each planting one goroutine leak I have met in real codebases, and asked the new profile to find them.

It found eight of them, and the two it missed taught me more about the design than the eight it caught.

All ten programs live in github.com/rezmoss/go127-goroutine-leaks, one folder per leak, runnable with go run. Every output in this article comes from those exact files running under go1.27.0 darwin/arm64 on an M-series MacBook

How The Detector Works

The design comes from Vlad Saioc’s work at Uber (proposal #74609), where the technique flagged a few hundred syntactically distinct leaks across 3,111 test suites, plus three more in a production service.

The idea reuses machinery the runtime already has. When you collect the profile, the runtime triggers a dedicated garbage collection cycle. Marking starts from the runnable goroutines only. Then it looks for goroutines blocked on a concurrency primitive that marking has reached: a channel, a sync.Mutex, a sync.WaitGroup, a sync.Cond. Those goroutines could still wake, so they become roots too and marking resumes from them. That repeats until nothing new can wake.

Whatever is still unmarked when marking stops is out of reach of anything that could ever run, so nothing is left to perform the operation it waits for: the receive that would complete a send, the send or close that would wake a receiver, the Done that would finish a WaitGroup, the Signal that would wake a Cond, the Unlock that would release a mutex. Those goroutines are provably stuck, and the profile reports each one with a (leaked) marker in its state:

goroutine 19 [chan send (leaked)]:

Reachability analysis buys the detector something most leak-hunting tools lack: zero false positives, since a goroutine it reports can never make progress again. That same proof standard costs it two of my ten leaks.

Collecting the profile takes one line:

// full dump with (leaked) markers, human readable
pprof.Lookup("goroutineleak").WriteTo(os.Stdout, 2)

debug=1 prints only the leaked goroutines, aggregated by stack with counts. debug=2 prints every goroutine, marking the leaked ones. Without a debug parameter, you get the binary protobuf format for go tool pprof. In a service, you skip the code and hit the endpoint that net/http/pprof now registers for free.

The Ten Leaks

Each example is an independently runnable main package. The pattern is the same in all of them: plant the leak, give the goroutine time to start and settle into its blocking or polling state, dump the profile, count (leaked) markers.

1. The Forgotten Sender

I see this one in code review more than any other. A worker sends its result on an unbuffered channel, the caller gives up after a timeout, and the send blocks forever:

func fetchQuote() string {
	ch := make(chan string) // unbuffered

	go func() {
		time.Sleep(50 * time.Millisecond) // slow backend call
		ch <- "the market is up"          // BUG: nobody will ever receive this
	}()

	select {
	case q := <-ch:
		return q
	case <-time.After(10 * time.Millisecond):
		return "no quote available" // caller walks away
	}
}

Once fetchQuote returns, the blocked sender is the only thing left holding ch. No goroutine that can still run, or that anything running could wake, has a way to reach it. The runtime agrees:

goroutine 19 [chan send (leaked)]:
main.fetchQuote.func1()
	01-forgotten-sender/main.go:23

Caught. The fix is a buffered channel (make(chan string, 1)) so the send completes, and the goroutine exits, or a context.Context the worker respects.

2. The Abandoned Receiver

Leak #2 mirrors the first. A consumer ranges over a channel, the producer finishes, and returns, and nobody calls close:

func processBatch(items []string) {
	events := make(chan string)

	go func() {
		for e := range events { // loop never ends: channel never closed
			fmt.Println("processed:", e)
		}
	}()

	for _, it := range items {
		events <- it
	}
	// BUG: missing close(events)
}
goroutine 19 [chan receive (leaked)]:
main.processBatch.func1()

Caught. One defer close(events) in the producer ends the range loop and the goroutine with it.

3. The Nil Channel

A struct with a channel field nobody initialized. Receiving from a nil channel blocks forever by language definition:

type worker struct {
	done chan struct{} // zero value is nil
}

func startWorker() {
	w := &worker{} // BUG: forgot make(chan struct{})

	go func() {
		<-w.done // never proceeds
		fmt.Println("worker shut down cleanly")
	}()
}

I expected this one to be an edge case since there is no channel object for the GC to trace. The runtime handles it with a dedicated state:

goroutine 19 [chan receive (nil chan) (leaked)]:

Caught. The runtime knows a nil-channel operation can never complete, so it never reaches for the reachability analysis.

4. The Deadlock Cycle

Two goroutines, each waiting to receive from the other before sending. Go’s classic deadlock detector fires when the whole process is blocked, so in a real service, this pair sits there unnoticed:

func startPair() {
	aToB := make(chan int)
	bToA := make(chan int)

	go func() {
		v := <-bToA // waits for B, but B is waiting for us
		aToB <- v + 1
	}()

	go func() {
		v := <-aToB
		bToA <- v + 1
	}()
}
goroutine 19 [chan receive (leaked)]:
goroutine 20 [chan receive (leaked)]:

Caught both goroutines. The two channels form a cycle reachable only from the two blocked goroutines, and the reachability analysis walks straight through it.

This case impressed me most: partial deadlocks are invisible to the runtime’s all-goroutines-blocked check, and finding them meant manual goroutine-dump analysis or a test-time leak checker.

5. The WaitGroup that never reaches zero.

An error path returns before wg.Done():

func runJobs(jobs []string) {
	var wg sync.WaitGroup

	for _, job := range jobs {
		wg.Add(1)
		go func() {
			if job == "corrupt" {
				return // BUG: skips wg.Done()
			}
			defer wg.Done()
			fmt.Println("finished:", job)
		}()
	}

	go func() {
		wg.Wait() // counter stuck at 1
		fmt.Println("all jobs complete")
	}()
}
goroutine 22 [sync.WaitGroup.Wait (leaked)]:

Caught. Put defer wg.Done() on the first line of the worker, before any early return can bypass it. Or on Go 1.25+, use wg.Go(func() { ... }) and let the standard library pair Add with Done for you.

6. The Mutex Nobody Unlocks

An error path returns while holding a lock, no defer in sight. Every later goroutine that wants the lock joins a queue that never moves:

c.mu.Lock()
if err := loadFromDB(c); err != nil {
	fmt.Println("refresh failed:", err)
	// BUG: returns while still holding c.mu
} else {
	c.mu.Unlock()
}

go func() {
	c.mu.Lock() // blocks forever
	defer c.mu.Unlock()
	fmt.Println("cache entries:", len(c.data))
}()
goroutine 19 [sync.Mutex.Lock (leaked)]:

Caught. defer mu.Unlock() on the line after Lock() remains the idiom that best survives refactoring.

7. The condition variable nobody signals

A consumer waits on a sync.Cond for work. The producer code that used to call Signal was refactored away:

ready := sync.NewCond(&mu)

go func() {
	mu.Lock()
	for len(queue) == 0 {
		ready.Wait() // nobody will ever Signal
	}
	// ...
}()
goroutine 19 [sync.Cond.Wait (leaked)]:

Caught. The release notes list sync.Cond as a supported primitive and the experiment confirms it.

8. The Abandoned Pipeline Stage

Adapted from the “stopping short” problem in the Go blog’s pipelines article. A generator streams values; the consumer takes three and breaks without cancelling anything:

func generate() <-chan int {
	ch := make(chan int)
	go func() {
		for i := 1; ; i++ {
			ch <- i * i // BUG: no context, no done channel
		}
	}()
	return ch
}

squares := generate()
for v := range squares {
	out = append(out, v)
	if len(out) == 3 {
		break // generator is now stuck mid-send
	}
}
goroutine 19 [chan send (leaked)]:
main.generate.func1()

Caught. A pipeline stage needs a way to hear that its consumer left; put a context.Context case in the select around the send.

9. The global channel

A cleanup goroutine waits for a shutdown signal on a package-level channel, and no code path ever closes it:

var shutdown = make(chan struct{}) // package-level

func startCleanupWorker() {
	go func() {
		<-shutdown // no code ever closes this
		fmt.Println("running cleanup before exit")
	}()
}

The goroutine shows up in the dump, blocked on chan receive, but without the marker:

goroutine 19 [chan receive]:
main.startCleanupWorker.func1()

---- runtime reported 0 leaked goroutine(s) ----

Missed. And the runtime is right to miss it by its own rules. shutdown is a global, so it stays reachable forever, and the detector cannot prove that no future code will close it. I know nothing ever will because I wrote the program. The GC only knows what the object graph says. The release notes name this exact case as a limitation: leaks blocked on primitives reachable through globals may go unreported.

Signal channels stored in package-level variables sit outside the detector’s vision, and the release notes name a second blind spot in the same sentence: primitives still held by local variables of runnable goroutines. Passing shutdown channels and contexts as arguments helps, but only once nothing global and no still-runnable goroutine retains a reference to the primitive.

10. The Sleep-Loop Poller

A goroutine polls a flag in a sleep loop, and the code that was supposed to set the flag is gone:

var stop atomic.Bool // nothing ever calls stop.Store(true)

go func() {
	for !stop.Load() {
		time.Sleep(10 * time.Millisecond)
	}
}()
goroutine 5 [sleep]:

---- runtime reported 0 leaked goroutine(s) ----

Missed, and this one is definitional. The detector looks for goroutines blocked on concurrency primitives. A sleeping poller is never blocked in that sense; every 10ms it wakes, checks, and sleeps again. From the scheduler’s point of view, it is a healthy, hardworking goroutine. It will also still be checking that flag when your process gets its SIGTERM three weeks from now.

Polling loops, forgotten time.Ticker consumers and tight for {} spins all live in this category. The plain goroutine profile still shows them, and grouping that dump by stack over time is what goroscope does, so I get to keep my tool for the leaks the runtime refuses to name.

The Scoreboard

#

Leak

Verdict

1

Forgotten sender after timeout

Caught

2

Receiver on a never-closed channel

Caught

3

Receive on a nil channel

Caught

4

Two-goroutine deadlock cycle

Caught (both)

5

WaitGroup with a missing Done

Caught

6

Mutex held by a dead code path

Caught

7

Cond. Wait with no signaler

Caught

8

Pipeline stage without cancellation

Caught

9

Wait on a global channel

Missed

10

Sleep-loop poller

Missed

Eight out of ten, nine leaked goroutines reported across the ten programs, zero false positives. Both misses are documented behavior rather than bugs, and both follow from the same design choice: the detector only reports goroutines it can prove are stuck.

Using it in a Real Service

Import net/http/pprof as usual, and the endpoint appears alongside the profiles you already know. My test service leaks one worker per timed-out request:

func lookupPrice(w http.ResponseWriter, r *http.Request) {
	result := make(chan string)

	go func() {
		time.Sleep(2 * time.Second) // slow upstream
		result <- "42.00"           // BUG: handler is long gone
	}()

	select {
	case price := <-result:
		fmt.Fprintln(w, "price:", price)
	case <-time.After(100 * time.Millisecond):
		http.Error(w, "upstream timeout", http.StatusGatewayTimeout)
	}
}

Three curls and one profile later:

$ curl "localhost:8080/debug/pprof/goroutineleak?debug=1"
goroutineleak profile: total 3
3 @ 0x... 0x... 0x... 0x... 0x...
#	main.lookupPrice.func1+0x3b	server/main.go:38

Three requests, three leaks, one aggregated stack pointing at the exact line. go tool pprof -http=:0 http://host/debug/pprof/goroutineleak works too and opens the web UI, flame graph included.

One caution before you copy the demo: it binds to 127.0.0.1 on purpose, and your service should be as careful. Keep /debug/pprof/ off the public mux, behind an internal listener or auth, since profiles expose stack detail and this one triggers a GC cycle on request.

One operational detail bit me during testing. My first profile came back with total 0 because the abandoned workers were still inside their two-second time.Sleep. A goroutine only qualifies once it blocks on the primitive, so you see a leak from a slow upstream call only after that call finishes. Long-running processes make settled leaks likely, but a profile is still a point-in-time view: workers created moments ago may not have blocked yet, so if you script a check, give the leaks time to settle.

Collection cost is a dedicated GC cycle per profile, so treat it like a heap profile: collect on demand first, measure what the extra GC cycle costs on your heap before putting it on a timer, and never call it in a hot loop…

Then What

Keep two habits from the misses. Avoid parking cancellation primitives in globals or in goroutines that stay runnable forever; passing ownership through arguments helps the reachability analysis once those other references go away. And keep an eye on total goroutine count, because pollers and tickers age outside the detector’s definition of stuck.

Go has had goroutine leaks since day one, and from 1.27 on the runtime will name the ones it can prove are stuck!

Happy coding!

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.