Goroutines are cheap. Each one starts with a small stack of a few kilobytes that grows as needed, and the runtime schedules them onto OS threads for you. That cheapness is what makes Go so pleasant for concurrent code, and it's also a trap.
The goroutines themselves are rarely the problem. The problem is whatever they talk to. Launch 50,000 goroutines that each hit the same database and you'll run out of connections, get throttled by an upstream API, or run out of memory holding 50,000 responses at once. Unbounded concurrency moves the bottleneck somewhere worse.
So the real question is usually not "how do I make this concurrent?" but "how do I make this concurrent with a limit?" Here are the three patterns I reach for.
The naive version
Let's start with the code that causes the incident:
func fetchAll(ctx context.Context, urls []string) []Result {
results := make([]Result, len(urls))
var wg sync.WaitGroup
for i, u := range urls {
wg.Add(1)
go func() {
defer wg.Done()
results[i] = fetch(ctx, u)
}()
}
wg.Wait()
return results
}
It works fine with 20 URLs. With 20,000 it opens 20,000 connections at once. It also has no error handling: if the first request fails because the upstream is down, all the others carry on anyway.
A side note: since Go 1.22, each loop iteration gets its own copy of i and u, so capturing them in the closure is safe. On older versions you had to pass them in as arguments. That's why you still see go func(i int, u string) {...}(i, u) in a lot of codebases.
Pattern 1: A classic worker pool
The traditional answer is a fixed number of workers reading from a shared channel:
func fetchAllPool(ctx context.Context, urls []string, workers int) []Result {
results := make([]Result, len(urls))
jobs := make(chan int)
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for i := range jobs {
results[i] = fetch(ctx, urls[i])
}
}()
}
for i := range urls {
jobs <- i
}
close(jobs)
wg.Wait()
return results
}
(for range workers ranges over an integer, which Go 1.22 allowed.)
At most workers requests run at once. Each worker writes to its own index in results, so no mutex is needed: different goroutines writing to different slice elements isn't a data race.
Worker pools are great when the workers hold expensive state, like a connection, a parser or a buffer you want to reuse, or when work streams in over time instead of arriving as one slice. For a fixed batch they're a bit more code than you need.
Pattern 2: A semaphore with a buffered channel
A buffered channel makes a perfectly good counting semaphore. Sending takes a slot and receiving gives it back:
func fetchAllSem(ctx context.Context, urls []string, limit int) []Result {
results := make([]Result, len(urls))
sem := make(chan struct{}, limit)
var wg sync.WaitGroup
for i, u := range urls {
sem <- struct{}{} // blocks once `limit` goroutines are in flight
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-sem }()
results[i] = fetch(ctx, u)
}()
}
wg.Wait()
return results
}
This keeps the one-goroutine-per-item shape but never lets more than limit run at the same time. If you need weighted limits, where some jobs cost more slots than others, golang.org/x/sync/semaphore has Acquire(ctx, n). It also respects context cancellation while it waits, which the plain channel version above doesn't.
Pattern 3: errgroup with SetLimit (my default)
Most of the time I want three things together: a concurrency limit, the first error, and cancellation of everything else once something fails. golang.org/x/sync/errgroup does all three:
func fetchAllGroup(ctx context.Context, urls []string, limit int) ([]Result, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(limit)
results := make([]Result, len(urls))
for i, u := range urls {
g.Go(func() error {
r, err := fetchErr(ctx, u)
if err != nil {
return fmt.Errorf("fetch %s: %w", u, err)
}
results[i] = r
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
What each piece does:
errgroup.WithContextreturns a derived context that is cancelled the first time any function returns a non-nil error, or whenWaitreturns, whichever happens first.SetLimit(limit)caps how many goroutines are active.g.Goblocks until there's room, so the loop itself provides backpressure.Waitblocks until every function has returned, then gives you the first non-nil error.
One subtle point: the cancellation only helps if fetchErr actually respects ctx. If it ignores the context, the other goroutines finish their work anyway and you just throw the results away. That's still correct, but wasteful.
Also, don't change the limit while goroutines in the group are running. The docs forbid it, so call SetLimit once, before the first Go. If you'd rather skip an item than wait for a slot, TryGo returns false instead of blocking.
Choosing a limit
There's no universal right number, but there are good places to start:
| Bottleneck | Sensible starting limit |
|---|---|
| Database | Below your connection pool size, since other requests need connections too |
| Rate-limited API | Whatever keeps you under the quota at your average latency |
| CPU-bound work | runtime.GOMAXPROCS(0) |
| Memory-heavy items | What fits in memory, with room to spare, when every slot is full |
Then measure. Latency percentiles and error rates will tell you when you've gone too far much sooner than your intuition will.
Which one when?
- errgroup + SetLimit: a batch of independent tasks where one failure should stop the rest. This is my default.
- Buffered-channel semaphore: the same shape, but you want to keep going on errors and collect them yourself.
- Worker pool: workers keep expensive state, or jobs arrive continuously from a queue.
All three do the same basic thing: they turn "as fast as possible" into "as fast as the system behind you can handle." That's usually the difference between a service that degrades gracefully and one that takes its database down with it.
Want to talk Go or backend architecture? Drop me a message on the contact page.