Goroutines are one of Go's biggest strengths. Starting a goroutine is inexpensive, which makes it natural to use concurrency for HTTP handlers, background jobs, message processing, database operations, and network calls.

That convenience can also hide a production problem: goroutines that never finish.

A goroutine leak occurs when goroutines remain alive because they are blocked on a channel, waiting for a lock, stuck on I/O, or otherwise unable to reach their intended termination point. The application may continue working for some time, while the number of goroutines steadily increases.

Eventually, the growing number of goroutines can consume memory and scheduler resources and may contribute to degraded performance.

Go's pprof tooling provides a practical way to investigate these problems. This article explains how goroutine profiles work, how to expose them safely, how to interpret blocked goroutines, and how to fix common leak patterns.

What Is a Goroutine Leak?

A goroutine leak is not necessarily a goroutine that is consuming CPU.

In many cases, the leaked goroutine is simply waiting forever.

For example:

func worker(ch <-chan string) {
    value := <-ch
    fmt.Println(value)
}

If the channel never receives a value and is never closed, the goroutine can remain blocked indefinitely.

Starting it repeatedly can create a leak:

for i := 0; i < 1000; i++ {
    go worker(make(chan string))
}

Every goroutine waits on its own channel.

Nothing tells those goroutines to stop.

Why Goroutine Leaks Matter

A single blocked goroutine is usually not a production incident.

The problem appears when the leak is repeated.

Consider a server handling requests:

Request
   |
   +-- Start goroutine
          |
          +-- Wait forever

If this happens for thousands of requests, the application accumulates goroutines.

You may eventually observe:

  • Increasing goroutine count

  • Increasing memory usage

  • Longer garbage-collection work

  • Resource exhaustion

  • Slow application shutdown

  • Requests waiting for internal workers

  • Unexpected connection or file-descriptor pressure

The exact symptoms depend on what the leaked goroutines retain.

How pprof Helps

Go provides the runtime/pprof package for profiling applications.

The goroutine profile answers a particularly useful question:

What are the currently running goroutines doing?

A basic HTTP profiling endpoint can be enabled with:

import (
    "net/http"
    _ "net/http/pprof"
)

func main() {
    go func() {
        http.ListenAndServe(
            "localhost:6060",
            nil,
        )
    }()

    // Application startup.
}

The blank import registers the pprof HTTP handlers.

For production applications, do not expose profiling endpoints publicly without appropriate access controls.

Reading the Goroutine Profile

A goroutine profile can be retrieved programmatically:

curl http://localhost:6060/debug/pprof/goroutine

You can also retrieve a human-readable debug representation:

curl "http://localhost:6060/debug/pprof/goroutine?debug=2"

The output contains stack traces for active goroutines.

A simplified example might look like:

goroutine 42 [chan receive]:
main.worker(...)
    /app/worker.go:12
created by main.startWorker
    /app/main.go:25

The important information is the state:

[chan receive]

This indicates that the goroutine is waiting to receive from a channel.

Other states may include:

[semacquire]

or:

[IO wait]

or:

[sleep]

The state alone does not prove a leak.

You need to understand why the goroutine is waiting and whether that waiting is expected.

Goroutine States to Investigate

Common states worth examining include:

State

Typical meaning

chan receive

Waiting to receive from a channel

chan send

Waiting to send to a channel

semacquire

Waiting for synchronization

IO wait

Waiting for network or other I/O

select

Waiting on a select statement

sleep

Sleeping or waiting on a timer

A production investigation should focus on patterns, not individual goroutines.

If hundreds or thousands of goroutines have nearly identical stack traces, that is often more interesting than one unusual goroutine.

Comparing Goroutine Profiles Over Time

A very useful diagnostic technique is to capture profiles at different points.

For example:

curl "http://localhost:6060/debug/pprof/goroutine?debug=2" > before.txt

After reproducing the suspected workload:

curl "http://localhost:6060/debug/pprof/goroutine?debug=2" > after.txt

Then compare them.

If the application starts with:

120 goroutines

and after repeated requests has:

2,500 goroutines

the next question is whether those goroutines eventually disappear.

If the count remains elevated after the workload completes, investigate the goroutine stacks.

A growing goroutine count is a signal, not proof of a leak.

Using the Go pprof Tool

The profiling endpoint can also be consumed by the Go profiling tools.

For example:

go tool pprof http://localhost:6060/debug/pprof/goroutine

Inside the interactive profiler, commands can help inspect the profile.

For example:

top

can show where goroutines are concentrated.

You can also inspect specific functions and stack information.

The exact output depends on the application and profile.

A Common Channel Leak

Consider:

func startWorker() {
    ch := make(chan string)

    go func() {
        message := <-ch
        fmt.Println(message)
    }()
}

startWorker() returns immediately.

The channel is local, but the goroutine remains blocked because nobody sends a value.

A better design is to provide a lifecycle mechanism.

func startWorker(ctx context.Context) {
    ch := make(chan string)

    go func() {
        select {
        case message := <-ch:
            fmt.Println(message)

        case <-ctx.Done():
            return
        }
    }()
}

Now cancellation provides a path for the goroutine to terminate.

Context Cancellation Is Critical

For request-scoped work, context.Context is one of the most important tools for controlling goroutine lifetime.

For example:

func process(ctx context.Context) error {
    result := make(chan string)

    go func() {
        result <- doWork()
    }()

    select {
    case value := <-result:
        fmt.Println(value)
        return nil

    case <-ctx.Done():
        return ctx.Err()
    }
}

The context allows the caller to stop waiting when the operation is cancelled.

However, there is another subtle problem here.

If doWork() continues running after cancellation, the worker goroutine may still remain alive.

The underlying operation should ideally accept the context too:

func doWork(ctx context.Context) (string, error) {
    // Respect ctx while performing work.
    return "done", nil
}

Cancellation is most effective when it propagates through the entire call chain.

Goroutine Leaks Caused by HTTP Requests

Consider an outbound request:

go func() {
    response, err := http.Get(url)

    if err != nil {
        return
    }

    defer response.Body.Close()
    processResponse(response)
}()

If this code is launched repeatedly without a lifecycle strategy, the application can accumulate work.

A better approach is to associate the operation with a context:

func fetch(ctx context.Context, url string) error {
    request, err := http.NewRequestWithContext(
        ctx,
        http.MethodGet,
        url,
        nil,
    )

    if err != nil {
        return err
    }

    response, err := http.DefaultClient.Do(request)
    if err != nil {
        return err
    }

    defer response.Body.Close()

    return processResponse(response)
}

Now cancellation can propagate to the HTTP request.

Goroutine Leaks from Unbuffered Channels

This pattern is another common source of blocked goroutines:

results := make(chan string)

go func() {
    results <- "complete"
}()

If the receiving side disappears before reading from the channel, the goroutine can remain blocked.

Depending on the design, a buffered channel can help:

results := make(chan string, 1)

go func() {
    results <- "complete"
}()

But buffering is not a universal fix.

If the producer can generate unlimited values while nobody consumes them, the buffer will eventually fill.

The real solution is to define ownership and cancellation clearly.

WaitGroup Misuse

sync.WaitGroup itself does not create goroutine leaks, but incorrect usage can leave an application waiting forever.

For example:

var wg sync.WaitGroup

wg.Add(1)

go func() {
    // Missing wg.Done()
}()

wg.Wait()

The Wait() call never completes.

The correct pattern is:

var wg sync.WaitGroup

wg.Add(1)

go func() {
    defer wg.Done()

    // Work
}()

wg.Wait()

When diagnosing shutdown problems, inspect goroutines waiting on synchronization primitives as well as goroutines blocked on channels.

Goroutine Leaks During Application Shutdown

Production services should have a clear shutdown path.

A common pattern is:

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

Workers can then listen for cancellation:

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return

        default:
            process()
        }
    }
}

For real workloads, avoid tight loops like this without appropriate blocking or scheduling behavior.

The important concept is that every long-lived goroutine should have a defined lifecycle.

Ask:

Who starts it? Who stops it? What happens if the work fails?

If those questions have no clear answers, the goroutine deserves closer inspection.

Common Mistakes When Using pprof

Exposing pprof Publicly

Do not expose:

/debug/pprof/

to the public internet without appropriate protection.

Profiles can contain detailed information about application internals.

Keep profiling endpoints restricted to trusted operators or internal networks.

Assuming Every Waiting Goroutine Is a Leak

A server normally has goroutines waiting on:

  • Network connections

  • Timers

  • Channels

  • Worker queues

The presence of blocked goroutines is normal.

The key question is whether they have a valid reason to remain alive.

Looking at Only the Goroutine Count

A count such as:

5,000 goroutines

is useful, but incomplete.

The stack traces tell you what those goroutines are actually doing.

Fixing Symptoms Instead of Lifecycle Problems

Increasing channel buffer sizes or adding timeouts may hide a problem temporarily.

First determine why the goroutine has no termination path.

A Practical Goroutine Leak Investigation

When a production service shows unusual goroutine growth, follow a structured process.

Step 1: Establish a Baseline

Record:

Current goroutine count
Memory usage
Request rate
CPU usage

Step 2: Reproduce the Workload

Run the operation suspected of causing the leak.

Step 3: Capture Another Profile

Collect the goroutine profile after the workload.

Step 4: Compare Stack Patterns

Look for repeated stacks.

For example:

500 goroutines
main.worker
worker.go:42

is much more useful than simply knowing that the application has 500 additional goroutines.

Step 5: Identify the Blocking Operation

Determine whether the goroutines are waiting on:

  • Channels

  • Locks

  • Network I/O

  • Timers

  • WaitGroups

  • External operations

Step 6: Trace Ownership

Find where those goroutines were created.

Ask:

Who starts them?
Who is supposed to stop them?
What event triggers termination?

Step 7: Add Cancellation

Use contexts, channel closure, or another appropriate lifecycle mechanism.

Step 8: Re-run the Workload

Capture another profile and verify that the goroutines now terminate.

Best Practices

  1. Give long-lived goroutines an explicit lifecycle.

  2. Use context.Context for cancellable work.

  3. Close channels only from the component that owns them.

  4. Use WaitGroup correctly with defer wg.Done().

  5. Avoid launching goroutines inside loops without a termination strategy.

  6. Use timeouts for operations that can wait indefinitely.

  7. Profile before changing concurrency code.

  8. Compare profiles instead of relying only on goroutine counts.

  9. Restrict access to pprof endpoints.

  10. Test graceful shutdown.

  11. Include cancellation tests in concurrent code.

  12. Monitor goroutine count as one signal among several.

Advantages and Disadvantages of pprof

Advantages

  • Built into the Go ecosystem.

  • Provides detailed goroutine stack information.

  • Useful for production troubleshooting when exposed safely.

  • Helps identify repeated blocking patterns.

  • Can be used alongside CPU, memory, and other profiles.

  • Supports both interactive and programmatic analysis.

Disadvantages

  • A profile shows symptoms, not necessarily the root cause.

  • Large applications can produce complex stack traces.

  • Developers need to understand Go concurrency primitives.

  • A single profile may not reveal a leak.

  • Incorrectly exposed profiling endpoints can create security concerns.

Conclusion

Goroutine leaks are usually lifecycle problems rather than simply "too many goroutines."

A goroutine should have a reason to exist and a clear way to stop. When that lifecycle breaks, blocked goroutines can accumulate silently until the application begins showing memory, latency, or shutdown problems.

Go's pprof tooling provides one of the most useful ways to investigate these situations. Instead of looking only at the total goroutine count, inspect the stack traces, identify repeated blocking patterns, determine where the goroutines were created, and trace how cancellation or completion is supposed to happen.

For production systems, combine pprof with proper context cancellation, controlled channel ownership, correct synchronization, and graceful shutdown. That combination makes concurrency easier to operate and makes goroutine leaks much easier to diagnose when they occur.