The leak does not announce itself
The first thing to understand about goroutine leaks is that they are usually silent. Your program compiles and runs. Tests pass. In development, the number of goroutines in flight is small enough that the leak never causes observable symptoms. It is only later, under sustained production load, that memory climbs steadily, the garbage collector has to work harder, and someone eventually runs runtime.NumGoroutine() and sees a number that should not be possible.
When we look at Go PRs from developers coming from other languages, goroutine lifecycle errors are among the most persistent problems we see. They appear in early PRs from beginners and they appear in code written by people who have been writing Go for years. The specific form changes, but the underlying confusion is usually the same.
What a goroutine leak actually is
A goroutine leak is a goroutine that was started and will never exit. It is blocked, waiting on a channel receive, a channel send, or a sync primitive that will never resolve. Because Go's garbage collector cannot reclaim the stack of a goroutine that is still technically running, the goroutine and everything it holds onto stays in memory until the process dies.
The simplest version looks like this:
func fetchResult() <-chan string {
ch := make(chan string)
go func() {
result, err := callExternalAPI()
if err != nil {
return // goroutine exits, but nobody will receive from ch
}
ch <- result
}()
return ch
}
If callExternalAPI returns an error, the goroutine exits and the channel is never closed. The caller is now blocked forever waiting to receive from a channel that will never produce a value. If it is the caller that times out and moves on, then nothing receives from ch, the goroutine is blocked on the send, and it leaks.
Flip it and you get the equally common mirror: a goroutine waiting to receive from a channel that the sender closed early or never sent to. Same result, different direction.
Why this trips up experienced developers
Beginners leak goroutines because they have not yet learned the ownership model. They see goroutines as "start and forget," which works fine for fire-and-forget operations but breaks immediately when there is any coordination involved. That part is understandable and fixable with straightforward explanation.
The reason senior developers still hit this is more subtle. Go's concurrency model is built on the idea that goroutines are cheap. The language actively encourages you to start them freely. And for the common patterns, go func() followed by a channel send or a WaitGroup.Done is so natural that the ownership question never arises. The problem appears in the exception paths: what happens to this goroutine if the caller returns early? What if the context is cancelled? What if the channel buffer fills up and nobody is draining it?
In a codebase that primarily uses goroutines for straightforward fan-out patterns, these questions rarely come up. When they do appear, the developer is often reasoning about the happy path, where everything completes in order, and the goroutine cleanup is not explicitly modeled.
The context pattern closes most of the gaps
The standard fix for goroutines that need to respect cancellation is to pass a context.Context into the goroutine and select on its Done() channel alongside whatever work the goroutine is doing:
func startWorker(ctx context.Context, jobs <-chan Job) {
go func() {
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
process(job)
}
}
}()
}
Now the goroutine has two exit conditions: either the jobs channel closes, or the context is cancelled. Neither involves waiting indefinitely for something that might not arrive.
The context pattern handles most real-world cases, which is why it is idiomatic Go for anything involving I/O, external calls, or work that needs to respect request lifecycle. But it requires the developer to think about cancellation as a first-class design concern, not an afterthought. That mental shift is the core of what we see developers making when they are building their Go model.
Channel ownership is the mental model worth building
The deeper concept under all of these patterns is channel ownership: which goroutine is responsible for closing a channel, and what invariants does that create for everyone reading from it?
In Go, a channel being closed is a signal that no more values will be sent. Closing a channel that has readers is intentional communication, not cleanup. Closing a channel that still has writers will panic. Never closing a channel that has blocked readers means those readers wait forever.
When ownership is clear, goroutine lifecycle follows from it. If goroutine A owns the channel and is responsible for closing it, then goroutines B and C that read from the channel will exit naturally when A closes it. The leak risk is concentrated in one place: A itself needs to close the channel before it exits, including in error paths.
The errors we see in PRs from developers who are still building this model tend to be ownership errors disguised as syntax errors. The code looks correct on the surface, channels are declared and goroutines are started, but the ownership relationship is not clear from reading the code. The channel might be closed by anyone, or by nobody. Once you are looking for ownership clarity rather than syntactic correctness, the problems become much more visible.
How this shows up in graded PRs
When HackQuest grades a PR that contains a goroutine leak pattern, the feedback is not just "this goroutine will never exit." That is the surface symptom. The concept being flagged is the ownership model: specifically, what entity owns the channel lifecycle, and does the code express that clearly?
We track whether this concept has appeared in previous PRs from the same developer. A developer who writes a goroutine leak in their first Go PR and then writes correct context-aware goroutines in the next three has internalized the model. A developer who is still writing goroutine leaks in their fifth PR, despite having received feedback on it twice, needs a different kind of lesson: not another example of the pattern, but something that builds the underlying ownership intuition more directly.
This is the distinction that separates learning-focused feedback from code review. A reviewer can tell you this goroutine leaks. Only a system with memory of your prior PRs can tell you this is the third time this concept has appeared in your code, and here is the specific model that seems to be missing.
Goroutine leaks are fixable. The fixes are not complicated once the ownership model is clear. Getting to that clarity is the actual learning work, and it takes more than one PR comment to happen.