alexrios
open main menu
orphaned goroutine illustration

When goroutines become orphans

/ 5 min read

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?

  1. You create a channel
  2. You spawn a child goroutine that tries to send 42 to that channel
  3. 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:

  1. Who will receive from this channel? If the answer is “maybe nobody,” you’re creating a potential orphan.

  2. How will this goroutine know when to stop? Context cancellation, channel close, or some other signal. It needs an exit path.

  3. 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.


Next: Hunting ghost goroutines →