
Goroutine management: errgroup, leak-proof, backpressure
- Golang
- August 25, 2025
Table of Contents
Last month, one of our team’s services suddenly slowed down like a turtle. The p50 latency jumped from 50ms to 3 seconds. Upon checking Grafana, there were 120,000 goroutines running - normally, there are only 200. Someone had just pushed code and forgotten to call defer cancel() in a batch processing loop.
Goroutines are what make Go delicious: lightweight (~2KB stack), cheap, and easy to start. But because they’re so easy to use, they’re also easy to leak. This article covers the patterns I use to keep goroutines under control - from errgroup to worker pools to backpressure. All of them have runnable code, most of which are extracted from production.

The First Issue: Goroutine Leak
Goroutine leak occurs when a goroutine never terminates. The #1 cause I’ve seen in code reviews is a channel being blocked indefinitely due to a lack of context.
// ❌ Incorrect: this goroutine will run indefinitely if ch is never closed
go func() {
for item := range ch {
process(item)
}
}()
// ✅ Correct: always have an exit through context
go func() {
for {
select {
case <-ctx.Done():
return
case item, ok := <-ch:
if !ok {
return
}
process(item)
}
}
}()
How to Detect Leaks: pprof is your best friend:
go tool pprof http://localhost:6060/debug/pprof/goroutine
# In pprof: top10 -> see which function is holding the most goroutines
If the number of goroutines increases steadily without decreasing, you have a leak. I once discovered a leak of 15K goroutines/hour simply by checking pprof before and after deployment.
Errgroup: Running Concurrently, Canceling Collectively
When you need to run 3-4 tasks concurrently and want to cancel all of them if one task fails, errgroup is the answer.
import "golang.org/x/sync/errgroup"
func fetchDashboard(ctx context.Context, userID string) (*Dashboard, error) {
g, ctx := errgroup.WithContext(ctx)
var users *UserList
var orders *OrderList
var stats *Stats
g.Go(func() error {
var err error
users, err = fetchUsers(ctx, userID)
return err
})
g.Go(func() error {
var err error
orders, err = fetchOrders(ctx, userID)
return err
})
g.Go(func() error {
var err error
stats, err = fetchStats(ctx, userID)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
return &Dashboard{users, orders, stats}, nil
}
When fetchOrders returns an error, errgroup automatically cancels the context, causing fetchUsers and fetchStats to receive ctx.Done() and stop early. No need for manual sync.WaitGroup + context.Cancel.
Errgroup with Limited Concurrency
import "golang.org/x/sync/errgroup"
func processBatchLimited(ctx context.Context, items []Item, maxConcurrent int) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(maxConcurrent) // Go 1.20+
for _, item := range items {
item := item // capture
g.Go(func() error {
return process(ctx, item)
})
}
return g.Wait()
}
Setting the limit to 10, errgroup will only run a maximum of 10 goroutines concurrently. I use this to process batches of 10K items without overloading the DB connection pool.
Worker pool: limiting the number of baristas
Not all the time a new goroutine is created. When the load is high, the worker pool limits the number of goroutines running concurrently - like limiting the number of counters in a coffee shop.
func processBatch(ctx context.Context, jobs []Job, numWorkers int) []Result {
ch := make(chan Job, len(jobs))
results := make(chan Result, len(jobs))
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-ch:
if !ok {
return
}
select {
case <-ctx.Done():
return
case results <- process(job):
}
}
}
}(i)
}
// Send jobs - also respect context
go func() {
defer close(ch)
for _, job := range jobs {
select {
case <-ctx.Done():
return
case ch <- job:
}
}
}()
wg.Wait()
close(results)
var output []Result
for r := range results {
output = append(output, r)
}
return output
}
This pattern I used in the service that handles batch import of merchant menus - 500 items/batch, 20 workers, p99 latency from 8s down to 1.2s.
Backpressure: when the restaurant is too crowded, don’t accept more customers
Backpressure is a technique that helps the system slow down when overloaded - instead of accepting everything and then crashing.
Buffered channel + timeout
ch := make(chan Job, 100)
select {
case ch <- job:
// OK, job is accepted
case <-time.After(50 * time.Millisecond):
// Queue is full for 100ms - report overload
metrics.OverloadCounter.Inc()
return ErrTooBusy
}
The client receives 429 or ErrTooBusy → knows to retry later. The system does not crash.
Rate limiting with token bucket
import "golang.org/x/time/rate"
var limiter = rate.NewLimiter(100, 200) // 100 req/s, burst 200
func handler(w http.ResponseWriter, r *http.Request) {
if err := limiter.Wait(r.Context()); err != nil {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
// Process...
}
I use a rate limiter for public-facing endpoints to prevent a single user from spamming and crashing the service. A burst of 200 allows for short spikes, while a steady 100 req/s handles sustained loads.
Observability: Knowing What Goroutines Are Doing
Running multiple goroutines without tracing is like searching for a needle in the ocean.
func HandleRequest(ctx context.Context, req *Request) (*Response, error) {
span, ctx := opentracing.StartSpanFromContext(ctx, "HandleRequest")
defer span.Finish()
// Assign attributes to the span
span.SetTag("user_id", req.UserID)
span.SetTag("item_count", len(req.Items))
// ctx automatically propagates the trace ID to all goroutines
result, err := processConcurrently(ctx, req.Items)
if err != nil {
span.SetTag("error", true)
span.LogFields(log.Error(err))
}
return result, err
}
Each goroutine shares a context with the same trace ID → Jaeger/Grafana Tempo displays the entire waterfall.
Goroutine Control Checklist
- Always have an exit route:
ctx.Done()orclose(channel) - Use
errgroupwhen needing to cancel collectively - Set
g.SetLimit(n)when handling large batches - Use a worker pool for sustained workloads (avoid spawning excessively)
- Implement backpressure (buffered channel + timeout) for public endpoints
- Use a rate limiter for sensitive endpoints
- Propagate trace ID through context
- Monitor goroutine count with pprof
“Goroutines are cheap, but not free. Each leaked goroutine is a barista standing idle, doing no work for any customer.”
Bottom line
Goroutines are what make Go distinct - but also what are easiest to shoot yourself in the foot with. Errgroup for parallel + cancel, worker pool for batch, backpressure for high load. These three patterns cover 90% of the use cases I encounter in production. The fourth pattern is… opening pprof to check before deploying. Always.
Have you ever encountered a goroutine leak of any kind? Is there a pattern that I haven’t mentioned that your team uses? Tell me about it - I’m still collecting war stories about concurrency here. 🚀


