The goroutine you forgot to cancel
A goroutine leak is not a bug in the runtime. It is a goroutine blocked forever on a channel operation that will never complete, holding everything it references alive with it.
The shape is almost always the same:
func handler(w http.ResponseWriter, r *http.Request) {
ch := make(chan result)
go func() { ch <- expensive() }() // unbuffered
select {
case res := <-ch:
write(w, res)
case <-r.Context().Done():
return // and the goroutine is stranded
}
}
When the client disconnects, handler returns. Nobody will ever receive from ch, so the send blocks forever. The goroutine, its stack, the result, and anything expensive() captured stay in memory until the process dies.
Two fixes, and the choice matters:
ch := make(chan result, 1) // send never blocks; goroutine always finishes
A buffer of one is enough because there is exactly one send. This is the right fix when expensive() is going to run to completion regardless and you simply stopped caring about the answer.
The other fix is to make expensive() itself take a context and stop early. That is more work and usually the correct thing, because a cancelled request should stop consuming database connections and CPU, not just stop being waited on.
Cancel is not optional
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
The defer cancel() is not defensive style. WithTimeout registers the child with its parent, and without cancel() that registration lives until the timeout fires. In a loop over a long-lived parent context you accumulate them. go vet catches the case where cancel is never called on any path — it does not catch calling it too late.
The time.After advice has expired
The classic warning was that time.After inside a select in a loop leaks a timer until it fires, so you should use time.NewTimer and Stop it.
As of Go 1.23 the runtime collects unreferenced timers without waiting for them to fire, so the plain time.After version no longer accumulates. If you are on 1.23 or later you can stop contorting that code. Check your go directive before believing either version of the advice — this is one of the few Go idioms where the right answer changed.
Finding them
Leaks are invisible until they are not, and then they look like a slow memory climb with no obvious allocation site. The goroutine profile is the fastest diagnosis available:
curl -s 'localhost:6060/debug/pprof/goroutine?debug=2' | head -50
debug=2 gives full stacks for every live goroutine, grouped. A leak shows up as hundreds or thousands of goroutines parked on the same line of your code, usually chan send or chan receive. You do not need to interpret anything subtle: the count and the repeated stack are the whole answer.
For a number you can alert on, runtime.NumGoroutine() as a gauge is crude and effective. A healthy service settles to a plateau under steady load. One that climbs monotonically and never comes back down after traffic drops is leaking, and the profile will tell you where within a minute.
The rule that avoids most of this
Whoever creates a channel owns closing it, and every goroutine you start should have an answer to "what makes this return?" that does not depend on a reader still being interested. If the answer is "the receiver reads it", you have written the bug above.