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:
- No limit.
len(urls)concurrent requests. The remote service rate-limits you, or your file descriptors run out, or both. - No errors.
fetchreturns one and it goes nowhere. - 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 jobChends when the channel closes. Forget the close andwg.Wait()blocks forever.- Every channel send is paired with
<-ctx.Done(). Without that, a worker sending to a fullresChthat 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.Goblocks once the limit is reached, until a slot frees up. Theforloop becomes its own back pressure — no jobs channel needed.errgroup.WithContextreturns a derived context that is cancelled the moment any goroutine returns a non-nil error. Shadowingctxwith it, as above, is deliberate: everyfetchgets 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.Joinis 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 capacity — TryGo 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.