How do goroutines leak, and how would you find a leak in a running service?
A goroutine leaks when it blocks forever on an operation nobody will complete: a send with no receiver, a receive on a channel nobody closes, or a worker abandoned after its consumer returned early. Fix it by giving every blocking operation a context escape hatch, and find it with pprof goroutine profiles.
What the interviewer is scoring
- Whether you can name a concrete leak shape rather than saying "goroutines that never finish"
- Whether you know the runtime cannot garbage-collect a blocked goroutine, so the leak is unbounded rather than self-healing
- Whether you treat cancellation as the caller's obligation to signal and the goroutine's obligation to observe, not as one library call
- Whether you can describe a diagnosis path on a live process without a debugger or a restart
- Whether you distinguish a leak from healthy transient growth under load
Answer
What a leak actually is
A goroutine leaks when it is permanently blocked on an operation that nothing will ever complete, and no reference to it exists that could ever unblock it. The important consequence is that the runtime has no way to reclaim it. There is no goroutine-level garbage collection: a goroutine parked on a channel operation is still a live root, so it keeps its stack, every variable in that stack, and the whole object graph reachable from those variables alive indefinitely. A leak therefore presents as two symptoms at once, a climbing goroutine count and a heap that grows without a matching increase in live traffic.
This is why "it will finish eventually" is the wrong instinct. A blocked goroutine is not slow, it is dead, and in a long-running service one leaked goroutine per request is an outage with a fuse on it.
The canonical shapes
Almost every real leak reduces to one of three patterns. The first is a send with no receiver: an unbuffered send parks until someone reads, and if the reader has gone away the sender parks forever. The second is a receive from a channel nobody closes, most often for x := range ch where the producer path returns early on an error and never reaches its close(ch). The third is a generalisation of the first, an abandoned worker after the consumer returns early, which is what happens when you fan out to N workers and then return after the first useful result.
That last shape is the one interviewers reach for, because it looks correct:
// LEAKS: N goroutines send, exactly one send is received.
func firstResult(urls []string) string {
ch := make(chan string) // unbuffered
for _, u := range urls {
go func(u string) {
ch <- fetch(u) // parks forever for all but the winner
}(u)
}
return <-ch // the other len(urls)-1 goroutines never return
}
A fourth shape is the forgotten ticker, and it is worth naming precisely because the answer changed. On Go 1.22 and earlier, an un-stopped time.NewTicker — or time.Tick, which hands you no stop handle at all — kept its runtime timer alive even after the goroutine returned. Go 1.23 made unreferenced timers and tickers eligible for garbage collection without Stop, so on any currently supported release this is no longer a leak. Stop still matters, for two reasons: it ceases the wakeups promptly rather than whenever the collector gets there, and a ticker still referenced by a live struct is still referenced, so it is still not collectable. Knowing which side of 1.23 you are on is the kind of detail that distinguishes someone who reads release notes.
Cancellation as the fix
The fix is a two-sided contract. The caller must have a way to say "I no longer care", and every blocking operation inside the goroutine must be selectable against that signal. context.Context is the standard carrier for the first half; select is the mechanism for the second.
func firstResult(ctx context.Context, urls []string) (string, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel() // signals the losers the moment we return
ch := make(chan string, len(urls)) // capacity for every sender
for _, u := range urls {
go func(u string) {
v, err := fetch(ctx, u) // ctx also aborts the in-flight I/O
if err != nil {
return
}
select {
case ch <- v:
case <-ctx.Done(): // nobody is reading any more; drop it
}
}(u)
}
select {
case v := <-ch:
return v, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
Two details carry the answer. The buffered channel alone removes the send-side leak here, because there is capacity for every possible send, but the select on ctx.Done() is what makes the pattern survive someone later changing the capacity or adding a second consumer. And passing ctx into fetch matters more than either: cancellation only unblocks operations that are watching for it, so a goroutine sitting in a net.Conn read with no deadline and no context ignores your cancel() entirely.
Finding one in a running service
Start with the cheap signal. runtime.NumGoroutine() exported as a metric, or the go_goroutines gauge the Prometheus Go collector publishes for free, tells you whether the count returns to baseline after a traffic spike. Healthy growth is proportional to concurrent load and decays; a leak is monotonic and survives idle periods.
Then get the shape from net/http/pprof, which you expose on an internal-only port. curl host/debug/pprof/goroutine?debug=1 returns every goroutine stack aggregated with a count per unique stack, and a leak is unmistakable there because one stack has an absurd count that grows between two samples. debug=2 prints each goroutine individually along with how long it has been blocked, which is what you want when the counts are similar and you need to know which ones are minutes old. For a graphical diff, fetch two profiles and use go tool pprof -base first.pb.gz second.pb.gz.
Read the top frames literally. chan send, chan receive, select, or semacquire tells you the blocking primitive, and the frame immediately beneath it names your own function and line. That pair is usually enough to identify the leak without reproducing it. In tests, go.uber.org/goleak asserts at the end of a test binary that no unexpected goroutines remain, which turns this class of bug into a build failure rather than a pager alert.
The trap
The trap is fixing the symptom by adding a buffer. Buffering converts "blocks immediately" into "blocks after N sends", which makes the leak disappear from your load test and reappear in production at a different scale. Worse, it can mask the real defect: if a send would have blocked forever, the buffer means the value is now silently discarded when nobody reads it, so you have traded a visible goroutine leak for invisible dropped work. A buffer is a legitimate fix only when you can prove the capacity is at least the number of possible sends, as in the example above. Otherwise the question to answer is not "how do I stop this send from blocking" but "who tells this goroutine to give up, and is it listening".
Every goroutine you start needs an answer to one question before you write it: what makes it return? If the answer is "the other side does the right thing", you have written a leak.
Likely follow-ups
- Why does buffering a channel fix some leaks but only hide others?
- Who is responsible for closing a channel, and what happens if two producers both close it?
- How would you write a test that fails when a function leaks a goroutine?
- A goroutine is blocked on a network read rather than a channel. Does cancelling the context unblock it?
Related questions
- Several producers write to one channel and one consumer reads it. Who closes the channel?mediumAlso on channels and goroutines4 min
- How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?hardAlso on pprof6 min
- A client hangs up halfway through a request. How do you structure a Go HTTP service so it stops doing the work?mediumAlso on context7 min
- Memory keeps climbing in a Go service under load. How do you find out why, and what can you actually tune?hardAlso on pprof5 min
- What is a closure, and how does one end up leaking memory?mediumAlso on memory-leaks4 min
- When would you use the observer pattern, and what goes wrong with it at scale?mediumAlso on memory-leaks4 min
- Design rejected the native control, so you are building a custom one. How do you make it accessible?hardSame kind of round: concept5 min
- A client disconnects but your server keeps working on their request. How does cancellation actually propagate in .NET?mediumSame kind of round: concept4 min