Go Programming

Worker Pools in Go: Bounded Concurrency with errgroup

Stop spawning unbounded goroutines. A practical guide to worker pools in Go using channels, sync.WaitGroup and errgroup.SetLimit — with error propagation, cancellation and result collection.

9 min read 1794 words
Worker Pools in Go: Bounded Concurrency with errgroup

Goroutines are so cheap that the first concurrent version of anything usually looks like for _, item := range items { go process(item) }. That works beautifully with ten items. With fifty thousand it opens fifty thousand database connections, and the thing you were trying to speed up falls over instead. What you almost always want is a bounded pool: N things in flight, no more. Here is how I build them.

The Problem With the Obvious Version

// Do not ship this.
func fetchAll(urls []string) {
    var wg sync.WaitGroup
    for _, url := range urls {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fetch(url)
        }()
    }
    wg.Wait()
}

Three separate problems:

  1. No limit. len(urls) concurrent requests. The remote service rate-limits you, or your file descriptors run out, or both.
  2. No errors. fetch returns one and it goes nowhere.
  3. No cancellation. If the caller gives up, every goroutine keeps running to completion.

The concurrency itself is not the mistake — the missing back pressure is.

The Classic Channel Pool

The traditional shape is a jobs channel, a fixed number of workers reading from it, and a results channel:

type job struct {
    ID  int
    URL string
}

type result struct {
    JobID int
    Body  []byte
    Err   error
}

func workerPool(ctx context.Context, jobs []job, workers int) []result {
    jobCh := make(chan job)
    resCh := make(chan result, len(jobs))

    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func(workerID int) {
            defer wg.Done()
            for j := range jobCh {
                body, err := fetch(ctx, j.URL)
                select {
                case resCh <- result{JobID: j.ID, Body: body, Err: err}:
                case <-ctx.Done():
                    return
                }
            }
        }(i)
    }

    // Feed the workers, stopping early if the caller cancels.
    go func() {
        defer close(jobCh)
        for _, j := range jobs {
            select {
            case jobCh <- j:
            case <-ctx.Done():
                return
            }
        }
    }()

    wg.Wait()
    close(resCh)

    out := make([]result, 0, len(jobs))
    for r := range resCh {
        out = append(out, r)
    }
    return out
}

This is worth understanding because you will read it in a lot of codebases, and because it shows the mechanics plainly. Note two things that are easy to get wrong:

  • close(jobCh) is the workers’ exit signal. for j := range jobCh ends when the channel closes. Forget the close and wg.Wait() blocks forever.
  • Every channel send is paired with <-ctx.Done(). Without that, a worker sending to a full resCh that nobody is reading leaks for the lifetime of the process.

It is also about forty lines to do something the standard extended library does in eight.

The errgroup Version

golang.org/x/sync/errgroup is a sync.WaitGroup that also collects the first error and cancels its siblings. SetLimit turns it into a bounded pool:

import "golang.org/x/sync/errgroup"

func fetchAll(ctx context.Context, urls []string) ([][]byte, error) {
    bodies := make([][]byte, len(urls))

    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(10) // at most 10 in flight

    for i, url := range urls {
        g.Go(func() error {
            body, err := fetch(ctx, url)
            if err != nil {
                return fmt.Errorf("fetch %s: %w", url, err)
            }
            // Each goroutine owns exactly one slot: no mutex needed.
            bodies[i] = body
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return bodies, nil
}

That is the whole pool. Behaviour worth knowing:

  • g.Go blocks once the limit is reached, until a slot frees up. The for loop becomes its own back pressure — no jobs channel needed.
  • errgroup.WithContext returns a derived context that is cancelled the moment any goroutine returns a non-nil error. Shadowing ctx with it, as above, is deliberate: every fetch gets the cancellable one.
  • g.Wait() returns the first error, and waits for the rest regardless. Later errors are discarded — if you need all of them, collect them yourself (errors.Join is a good fit, see error handling in Go).
  • Writing to bodies[i] is safe without a mutex because each goroutine writes one distinct element. Different elements of a slice are different memory; that is not a data race. Appending to a shared slice, or writing to a shared map, absolutely is — see concurrent map writing and reading in Go for what that failure looks like.

A Note on Loop Variables

The example above relies on Go 1.22’s per-iteration loop variables. Before 1.22, i and url were shared across iterations and every goroutine would see the final values — the single most common concurrency bug in Go. On older versions you must copy them:

for i, url := range urls {
    i, url := i, url // required before Go 1.22
    g.Go(func() error { /* ... */ })
}

Since Go 1.22 the copy is unnecessary. Leaving it in is harmless, and I still write it in code that must build on older toolchains. The loop semantics change was one of the more consequential recent additions to the language — I touched on the surrounding rules in mastering Golang for loops.

Streaming Results Instead of Preallocating

Indexing into a preallocated slice only works when you know the number of jobs up front. For a stream, send results down a channel and read them concurrently:

func processStream(ctx context.Context, in <-chan job, workers int) (<-chan result, func() error) {
    out := make(chan result)

    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(workers)

    go func() {
        // Closing `out` after every worker has finished lets the consumer
        // range over it and stop naturally.
        defer close(out)

        for j := range in {
            g.Go(func() error {
                body, err := fetch(ctx, j.URL)
                if err != nil {
                    return fmt.Errorf("job %d: %w", j.ID, err)
                }
                select {
                case out <- result{JobID: j.ID, Body: body}:
                    return nil
                case <-ctx.Done():
                    return ctx.Err()
                }
            })
        }
        _ = g.Wait()
    }()

    return out, g.Wait
}

The consumer ranges over out and then calls the returned function to get the error:

results, wait := processStream(ctx, jobs, 8)
for r := range results {
    save(r)
}
if err := wait(); err != nil {
    return fmt.Errorf("process stream: %w", err)
}

g.Wait() is safe to call more than once — subsequent calls return the same error immediately.

Picking the Limit

There is no universal number, but there is a reliable way to think about it.

CPU-bound work — parsing, hashing, image resizing, compression — saturates at roughly the number of cores. More goroutines just add scheduling overhead:

g.SetLimit(runtime.GOMAXPROCS(0))

I/O-bound work — HTTP calls, database queries, object storage — spends most of its time waiting, so the useful limit is much higher. But it is not “as high as possible”: it is whatever the slowest downstream dependency can absorb. If your database pool has 25 connections, a pool of 200 workers means 175 goroutines queueing on a mutex inside database/sql while your latency graph climbs.

// Match the constraint that actually binds.
g.SetLimit(db.Stats().MaxOpenConnections)

For outbound HTTP, remember that Go’s default transport keeps only 2 idle connections per host. Exceed that and you are opening a fresh TCP connection — plus a TLS handshake — for each extra request:

transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxIdleConnsPerHost = 50
transport.MaxConnsPerHost = 50
client := &http.Client{Transport: transport, Timeout: 10 * time.Second}

Then set the pool limit to match. Tuning one without the other gets you nothing.

Whatever you pick, measure it. Run the job at 5, 10, 25 and 50 and look at total wall time and downstream latency — the fastest setting for your batch is often the one that makes everything else on the system slower. Load testing is the honest way to find out; I wrote about a lightweight setup in an easy way to load test your web apps.

Not Every Goroutine Belongs in a Pool

A pool is for a batch of similar work. Some situations want something else:

Waiting on several different things at once — no limit needed, just a group:

g, ctx := errgroup.WithContext(ctx)

var user *User
var orders []Order

g.Go(func() (err error) { user, err = loadUser(ctx, id); return })
g.Go(func() (err error) { orders, err = loadOrders(ctx, id); return })

if err := g.Wait(); err != nil {
    return nil, fmt.Errorf("load profile: %w", err)
}

Three sequential 100ms calls become one 100ms call. This is the highest-value use of errgroup in a typical request handler, and it needs no pool at all.

Work that must happen in order — a pool is the wrong shape entirely; you want a single consumer, like the simple queue implementation I wrote about earlier.

Fire-and-forget background work — resist it. A goroutine started in a request handler outlives the request, holds whatever it captured, and will be killed mid-flight when the process shuts down. If it matters, it belongs in a durable queue; if it does not, do it inline. The same reasoning applies at shutdown time, which I covered in graceful shutdown in Go web services.

Only trying if there is capacityTryGo starts the goroutine only if a slot is free, and reports whether it did:

if !g.TryGo(func() error { return prefetch(ctx, url) }) {
    // Pool is busy; skip this optional work rather than blocking.
    metrics.PrefetchSkipped.Inc()
}

Pitfalls

Pitfall Fix
g.Go never returns Something inside blocks forever — give every call a context and a timeout
Results come back in the wrong order Index into a preallocated slice, or sort by an explicit sequence number
panic in a worker kills the process Recover inside the goroutine and convert it to an error
Errors vanish Return them from g.Go; do not just log them
SetLimit called after g.Go Panics — set the limit before starting any work
Unbounded jobs channel eats memory Use an unbuffered channel, or let SetLimit provide the back pressure
Shared map written from workers sync.Map, a mutex, or per-worker maps merged at the end

Panic recovery is worth spelling out, because one bad input taking down the whole process is a common way for batch jobs to fail:

g.Go(func() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic processing %s: %v", url, r)
        }
    }()
    return process(ctx, url)
})

The named return value err is what makes this work — the deferred function assigns to it after the panic is recovered.

Conclusion

The pattern is small: pick a limit that matches your real bottleneck, use errgroup.WithContext so failures cancel their siblings, return errors instead of logging them, and give every blocking operation a context. Most of the time that is eight lines and no channel plumbing at all. Save the hand-rolled channel pool for the cases where you genuinely need to stream results or vary the shape of the work — and when you do write one, remember to close the jobs channel.

One place this pattern turns up more than you would expect: running the tool calls an LLM asks for, several at a time but not unboundedly — see tool use in Go.