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 |
|---|---|
| Waiting to receive from a channel |
| Waiting to send to a channel |
| Waiting for synchronization |
| Waiting for network or other I/O |
| Waiting on a |
| 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
Give long-lived goroutines an explicit lifecycle.
Use
context.Contextfor cancellable work.Close channels only from the component that owns them.
Use
WaitGroupcorrectly withdefer wg.Done().Avoid launching goroutines inside loops without a termination strategy.
Use timeouts for operations that can wait indefinitely.
Profile before changing concurrency code.
Compare profiles instead of relying only on goroutine counts.
Restrict access to pprof endpoints.
Test graceful shutdown.
Include cancellation tests in concurrent code.
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.

Join the conversation! Your thoughts help the community grow.