Goroutine management: errgroup, leak-proof, backpressure

Goroutine management: errgroup, leak-proof, backpressure

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.

Managing Goroutines

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() or close(channel)
  • Use errgroup when 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. 🚀

Share :

Related Posts

When to use cache and when not to

When to use cache and when not to

There is a production bug that I still remember vividly: user A cancels an order, but the app still displays “delivering” for the next 10 minutes. Support receives 30 tickets in one morning. The reason: cache TTL is 10 minutes, but no one invalidates it when the order status changes.

Read More
Context in Go: passing data, deadline, cancel - use correctly or die of performance

Context in Go: passing data, deadline, cancel - use correctly or die of performance

When I first coded in Go, I encountered a strange production bug: after running for 3-4 days, the service suddenly consumed 8GB of RAM and caused an OOM error. I spent the entire Friday afternoon debugging and finally discovered the issue: a goroutine never ended because I forgot to pass the context to the gRPC call. Each request leaked a goroutine, and after 100K requests, the server was overwhelmed.

Read More
API Filtering: retrieving data like a coffee connoisseur

API Filtering: retrieving data like a coffee connoisseur

In my first year of working, I once wrote an endpoint GET /api/menus that returned… the entire menu. 200 items every time it was called. The JSON was 1.2MB heavy. The frontend only needed the name and price of 10 active dishes. I remember the first thing my lead said: “You’re sending the entire warehouse to someone who just needs to view the menu, aren’t you?”

Read More