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

Wait 5 sec.

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 timerRuntime 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 MacBookHow The Detector WorksThe 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 readablepprof.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 LeaksEach 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 SenderI 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) // unbufferedgo func() {time.Sleep(50 * time.Millisecond) // slow backend callch