- Book: The Complete Guide to Go Programming
- Also by me: Pocket Guides for Developers ā the 8-book series
- My project: Hermes IDE | GitHub ā an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
When researchers pointed a prototype detector at one of Uber's production Go services, it found hundreds of previously hidden partial deadlocks: goroutines parked on a channel or a lock that nothing would ever release. Each one kept its stack and everything that stack pointed to. None of them raised an error.
That detector now lives in the Go runtime. Go 1.26 shipped it as an experimental profile behind GOEXPERIMENT=goroutineleakprofile. Go 1.27, released on 19 August 2026, made the goroutineleak profile generally available and deleted the experiment flag. You read it from runtime/pprof or from the /debug/pprof/goroutineleak endpoint.
The interesting part is how it decides that a goroutine is leaked. There are no timers and no "blocked for more than ten minutes" heuristics. The runtime asks the garbage collector a reachability question. Once you see the question, you can predict which leaks it will report and which ones it will never notice.
What "leaked" means to the runtime
The release notes define it precisely: a leaked goroutine is one "blocked on some concurrency primitive (channels, sync.Mutex, sync.Cond, etc) that cannot possibly become unblocked."
Think about what it takes to wake a goroutine stuck on ch <- v. Some other goroutine has to run <-ch. To do that, it needs a reference to ch. If no goroutine that could ever run again holds a pointer to that channel, the send can never complete. And "who holds a pointer to what" is exactly what the garbage collector computes on every cycle.
The Go blog post on leak profiles states the rule inductively. A goroutine is live if it is not blocked, or if at least one primitive blocking it is referenced by another live goroutine. Everything else is leaked.
The primitives covered are channel sends and receives (nil channels included), blocking select (empty select {} included), sync.Mutex, sync.RWMutex, sync.WaitGroup and sync.Cond, per the same post. A goroutine blocked on file or network IO, a raw system call, or a hand-rolled primitive that is not built on those is never reported.
How the mark phase answers "can anything wake this up?"
In a normal GC cycle, every goroutine's stack is a root. A leak-detection cycle changes that. The design document by Georgian-Vlad Saioc and Milind Chabbi lays out the loop:
- Treat only runnable goroutines as goroutine roots.
- Mark everything reachable from the roots.
- Check each unmarked, blocked goroutine. If the primitive it waits on was marked, some live goroutine can reach it, so this goroutine is "eventually runnable." Add it as a root.
- Resume marking from step 2. Stop when no new goroutine gets promoted.
- Every blocked goroutine that never became a root is leaked. Mark from those too, so the heap ends up marked the same way a regular cycle would mark it, then sweep as usual.
That final step matters for what you expect in production. The profile reports leaks; it does not reclaim them. The blog says the last round lets the GC "mark all the memory it would have marked during a regular run."
There is one wiring problem the loop has to solve. A blocked goroutine is attached to its channel through a sudog, the runtime's record for "a g in a wait list." In runtime2.go, the sudog's channel field is a maybeTraceableChan and its data field is a maybeTraceablePtr. Sudogs are reachable from the global goroutine list and the semaphore table, according to the design doc. If the GC traced those fields normally, every channel with a waiter would be marked through the waiter itself, and nothing would ever look leaked.
So the pointer is stored twice: once as a uintptr (vu, the "source of truth") and once as an unsafe.Pointer (vp, "for liveness only"). When a leak-detection cycle starts, the runtime makes the vp half of every sudog untraceable, so the GC cannot follow the pointer while the address stays recorded. When the cycle ends, it makes them traceable again. The waiter still knows which channel it is parked on, and that knowledge no longer counts as a reason to keep the channel alive.
The blocked goroutine's own stack gets the same treatment by construction. It is not a root until it gets promoted, so the ch variable sitting in its closure does not mark the channel either. A goroutine that survives the whole loop ends up in a new status, _Gleaked, which runtime2.go documents as "a leaked goroutine caught by the GC."
A leak it catches
Here is the classic timeout leak. The worker sends on an unbuffered channel, the caller gives up first, and the send blocks forever.
package main
import (
"context"
"fmt"
"os"
"runtime/pprof"
"time"
)
func fetch(ctx context.Context) error {
ch := make(chan error) // unbuffered: the bug
go func() {
time.Sleep(50 * time.Millisecond)
ch <- nil // nobody receives after a timeout
}()
select {
case err := <-ch:
return err
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
for range 3 {
ctx, cancel := context.WithTimeout(
context.Background(), 10*time.Millisecond)
_ = fetch(ctx)
cancel()
}
time.Sleep(200 * time.Millisecond) // senders block
p := pprof.Lookup("goroutineleak")
if p == nil {
fmt.Println("needs Go 1.27, or 1.26 with " +
"GOEXPERIMENT=goroutineleakprofile")
return
}
p.WriteTo(os.Stdout, 1)
}
On Go 1.27, go run . is enough. On Go 1.26, build with GOEXPERIMENT=goroutineleakprofile go run .. Writing the profile triggers a leak-detecting GC cycle and waits for it, as the design doc describes, and debug=1 gives you the text format with function names and line numbers.
Walk through it with the loop above. After each fetch returns, main's frame no longer holds ch. The only remaining references live in the sender's closure and in its sudog. The sender is blocked, so its stack is not a root. The sudog's channel pointer is hidden for the cycle. Nothing marks the channel, so none of the three senders gets promoted, and all three should land in the profile with stacks pointing at the ch <- nil line. I haven't run this exact snippet, so treat that as what the mechanism predicts, not pasted output.
The fix is the one the Go blog gives for its own timeout example: give the channel a buffer of size 1. Then make(chan error, 1) lets the late sender drop its value and exit.
A leak it cannot see
Now move the channel to package level.
var results = make(chan error) // package-level
func fetchGlobal(ctx context.Context) error {
go func() {
time.Sleep(50 * time.Millisecond)
results <- nil // same leak as before
}()
select {
case err := <-results:
return err
case <-ctx.Done():
return ctx.Err()
}
}
The leak is the same: every timed-out call can strand a sender. Sharing the channel also adds a second bug, because a later call can now receive an earlier call's stale result. The profile stays quiet.
Package-level variables are always roots. In step 2 the GC marks results straight from the global, the blocked senders look "eventually runnable" in step 3, and they never reach the leaked set. The Go 1.26 release notes spell this out: "the runtime may fail to identify leaks caused by blocking on concurrency primitives reachable through global variables or the local variables of runnable goroutines."
The second half of that sentence catches more real code than the first. A long-lived worker goroutine that keeps a map of per-request channels in a local variable, and never reads some of them, keeps every one of those channels marked. So does a struct of channels hanging off a server object that a running goroutine still references. The design doc puts it plainly: the detector's "effectiveness is reduced the more heap resources are over-exposed in memory."
That gives you a design rule the detector rewards. Create channels as close to their use as you can, scope them to one request or one call, and do not park them in registries that outlive the work. The narrower the reachability, the more the GC can prove.
Running it in production and in tests
If a service already imports net/http/pprof, Go 1.27 adds the endpoint with no code change. The blog's workflow, with the address left as a placeholder:
# ADDR: wherever net/http/pprof listens in your service
curl "$ADDR/debug/pprof/goroutineleak" > leak.prof
go tool pprof leak.prof
Inside pprof, list fetch (or whatever function spawned the goroutine) shows the blocked line in context.
On cost, the Go 1.26 notes say the feature is designed "to not incur any additional run-time overhead unless it is actively in-use." When you do request it, there is a worst case. The blog describes a leak-free "daisy chain" where runnable G0 references the primitive blocking G1, G1 references the one blocking G2, and so on. Each link only proves live after the previous one, which serializes marking and gives O(n²) steps for one GC cycle, with n the number of goroutines. The blog's answer is to profile periodically, every 4 hours in its example, since a leak that can be observed once stays observable.
The design doc calls the approach "theoretically sound, i.e., there are no false positives," and the blog describes it as "precise" with "little-to-no false positives," so treat a goroutine in the report as a strong signal that it is stuck. The detector also only sees leaks that have already happened. The blog recommends pairing it with goleak in test suites, which flags goroutines still running when a test ends, and with testing/synctest, whose fake clock lets a timeout path run deterministically.
When the profile comes back empty and memory still climbs, check your package-level channels first.
If this was useful
The leak profile makes sense once you know what the runtime does underneath your code: how goroutines park on channels and what the garbage collector treats as a root. The Complete Guide to Go Programming covers the runtime and its concurrency primitives with that picture in mind, including the channel and context patterns that keep a goroutine from getting stranded in the first place.







![[Agent Platform in Practice (4)] Towards Cloud Two-layer Decoupling and Full Persistence: Completing the LINE AI Business Card M](https://media2.dev.to/dynamic/image/width=1200,height=627,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fla3l7bjvp6jd5pq42om9.png)

![[Agent Platform in Action (1)] Building a LINE AI Business Card Master with Go and Google ADK 2.0: From Fan-Out Parallel Retrieval to HITL Pause](https://media2.dev.to/dynamic/image/width=1200,height=627,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4y60ed2tg4coy6x3auw0.png)



