
When goroutines become orphans
You spawn a goroutine. It starts running. Then it waits. And waits. And waits.
Meanwhile, your program moves on. The function that created that goroutine returns. Life continues. But somewhere in the background, that goroutine is still there. Waiting for something that will never come.
It’s not crashed. It’s not dead. It’s just… stuck. Forever. An orphan.
The parent-child relationship
Here’s a mental model that will save you hours of debugging:
When you spawn a goroutine, liking it or not, you become a parent. Congratulations!
go func() {
// This is your child
}()
And like any responsible parent, you have obligations. Your child needs to be able to finish its work and go home. If you leave before making sure that can happen, you’ve abandoned them.
Think of it like dropping your kid at a bus stop:
Wait here. A bus will come and pick you up
Then you drive away. But what if the bus was cancelled? What if nobody told your kid? They’ll stand there forever, waiting for a bus that will never arrive.
That’s a goroutine leak.
The simplest leak
Let’s see what this looks like in code:
func leakyFunction() {
ch := make(chan int)
go func() {
// Child: "I'll send this value and wait for someone to receive it"
ch <- 42
}()
// Parent: leaves immediately
return
}
What happens here?
- You create a channel
- You spawn a child goroutine that tries to send
42to that channel - You return immediately
The child tries to send… but who’s receiving? Nobody. The channel is unbuffered, which means the sender blocks until a receiver is ready. But you, the parent, just left. The function returned. The local variable ch is gone.
Your child is now an orphan, blocked forever on a send that will never complete.
Parent goroutine Child goroutine
| |
|-------- go func() --------->|
| |
| return | ch <- 42
X | (blocked...)
| (blocked...)
| (blocked forever)
The parent’s timeline ends. The child’s never does.
Okay, one stuck goroutine. So what?
I have news for you: Go doesn’t have a goroutine garbage collector.
The runtime is excellent at cleaning up unused memory. Objects that nobody references get swept away. But goroutines aren’t objects. A stuck goroutine is still “running” from the runtime’s perspective. It’s just running a blocking operation that happens to take forever.
Each orphaned goroutine holds onto:
- Its stack (minimum 2KB, can grow much larger)
- Any variables it captured
- Any resources it opened
In a server handling thousands of requests per second, if each request has even a small chance of orphaning a goroutine, you’re accumulating dead weight. Over hours or days, memory climbs. Eventually, things break.
The worst part? These leaks are silent. No crash. No error. Just a slow accumulation of abandoned children (A.K.A. slow degradation).
The responsible parent
So how do you avoid creating orphans? Give your children a way to know when you’re leaving.
The most common approach: context.
func responsibleFunction(ctx context.Context) {
ch := make(chan int)
go func() {
select {
case ch <- 42:
// Success: someone received our value
case <-ctx.Done():
// Parent is leaving, we should too
return
}
}()
// Even if we return early, the child has an escape route
return
}
Now the child has two options: send the value, or notice that the context was cancelled and exit cleanly. The select statement lets it respond to whichever happens first.
Parent goroutine Child goroutine
| |
|-------- go func() --------->|
| |
| return | select {
| cancel() ------------------>| case <-ctx.Done():
X X return
Both timelines end. No orphans. Progress!
The three questions
Before spawning any goroutine, ask yourself:
-
Who will receive from this channel? If the answer is “maybe nobody,” you’re creating a potential orphan.
-
How will this goroutine know when to stop? Context cancellation, channel close, or some other signal. It needs an exit path.
-
What happens if I return early? Error paths are where orphans are born. That
if err != nil { return err }might be abandoning a child.
If you can answer these three questions confidently, you’re being a responsible parent. No Office of Childrengoroutine’s Issues.
Going deeper
This is the core concept. The parent-child relationship is the mental model that makes goroutine leaks click.
But there’s more to this story.
In Go 1.26, the runtime team introduced an experimental goroutine leak detector. It can actually tell you which goroutines are orphaned, not just that you have goroutines waiting, but which ones will never wake up.
The next post explores how this works, starting with a real-world example that shows just how easy it is to create orphans in production code.