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

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