Bounded Concurrency in Go with a Worker Pool
Goroutines are cheap, but the resources they call are often not. Starting one goroutine for every item in a large batch can overwhelm a database connection pool, trigger API rate limits, exhaust file descriptors, or create avoidable memory pressure.
Bounded concurrency solves this by allowing only a fixed number of operations to run at the same time. A worker pool is one of the simplest standard-library patterns for implementing that limit in Go.
This guide builds a small generic worker pool, explains why it is race-safe, and shows where the pattern fits better than launching an unbounded number of goroutines.
The problem with one goroutine per item
A straightforward concurrent loop often looks like this:
for _, item := range items {
go process(item)
}For ten items, that may be fine. For hundreds of thousands of items, it means hundreds of thousands of goroutines can become runnable or blocked at once.
The application may then move the bottleneck somewhere less controlled: the database, an upstream HTTP service, the filesystem, or the scheduler itself.
A concurrency limit makes resource usage intentional.
A small generic worker pool
The following helper accepts a slice, a maximum concurrency level, and a function to execute for each item. Results are returned in the same order as the input.
package main
import (
"fmt"
"sync"
)
func MapLimit[T any, R any](items []T, limit int, fn func(T) R) []R {
if limit < 1 {
panic("limit must be at least 1")
}
results := make([]R, len(items))
jobs := make(chan int)
workers := limit
if len(items) < workers {
workers = len(items)
}
var wg sync.WaitGroup
wg.Add(workers)
for range workers {
go func() {
defer wg.Done()
for i := range jobs {
results[i] = fn(items[i])
}
}()
}
for i := range items {
jobs <- i
}
close(jobs)
wg.Wait()
return results
}
func main() {
squares := MapLimit([]int{1, 2, 3, 4}, 2, func(v int) int {
return v * v
})
fmt.Println(squares)
}The output is:
[1 4 9 16]The for range workers integer-range syntax requires Go 1.22 or newer. On older Go versions, use a conventional for i := 0; i < workers; i++ loop instead.
How the limit works
The jobs channel carries indexes rather than values. Each worker repeatedly receives an index, processes the corresponding input, and stores the result at the same position.
Only workers goroutines execute fn concurrently. If limit is 8, at most eight calls to fn are active because there are only eight worker goroutines.
The producer sends every index into the unbuffered channel. When all workers are busy, the next send waits until a worker is ready. This creates natural backpressure instead of allowing work to accumulate without a bound.
Why concurrent writes to the result slice are safe
Multiple workers share the results slice, but each job index is sent exactly once. Therefore, two workers never write the same element.
Concurrent access to different slice elements is safe as long as no goroutine changes the slice header itself. The slice is allocated at its final length before workers start, and it is never appended to or resized.
wg.Wait() also ensures all workers have finished before the caller reads the completed result set.
Why indexes are useful
Sending indexes through the jobs channel has two useful properties.
First, output ordering is deterministic even when tasks finish out of order. A slow operation for items[0] does not cause its result to move behind a faster operation for items[1].
Second, the channel does not need a separate job struct containing both the value and its destination position.
For workloads where output order does not matter, workers can instead send completed values to a results channel.
Choosing a concurrency limit
There is no universal best worker count. The right limit depends on the constrained resource.
For database work, a useful upper bound is often related to the database connection pool rather than the number of CPU cores. Running 100 database workers against a pool of 10 connections usually creates waiting rather than useful parallelism.
For external HTTP APIs, consider documented rate limits, upstream capacity, request latency, and the HTTP transport’s connection behavior.
For CPU-heavy work, a limit near the available CPU parallelism is often a better starting point than a very large worker count.
Measure the real workload instead of assuming that more concurrency always means more throughput.
Worker pools versus semaphores
Another common bounded-concurrency pattern uses a buffered channel as a semaphore:
sem := make(chan struct{}, limit)
for _, item := range items {
sem <- struct{}{}
go func() {
defer func() { <-sem }()
process(item)
}()
}This limits active work, but it can still create a goroutine for every item depending on where semaphore acquisition occurs. A fixed worker pool keeps the number of long-lived worker goroutines bounded as well.
Worker pools are particularly convenient for batch processing and queues. Semaphores are often convenient when concurrency limiting needs to wrap operations scattered through existing code.
Handling errors
The minimal MapLimit helper assumes fn always produces a value. Production work often needs error handling.
There are two important decisions to make before adding it:
- Should processing stop after the first error?
- Should already-running jobs be allowed to finish?
If the answer is “stop as soon as possible,” combine the worker pool with context.Context. Workers should check cancellation before accepting more work, and the first meaningful error can cancel the shared context.
If every item must be attempted, store a result type containing both the value and its error instead of canceling the pool.
Do not add cancellation mechanically. The desired failure semantics are part of the API contract.
Avoid closing channels from workers
The goroutine that owns job production should close the jobs channel after it has sent all work.
Workers should not close jobs. Multiple workers cannot safely decide independently that no more values will be sent, and closing a channel while another goroutine is sending causes a panic.
A useful rule is: the sender that knows no more values will be produced owns the close operation.
Be careful with panics
If fn panics, the process normally panics as well. defer wg.Done() still runs while that goroutine unwinds, but the panic is not converted into an ordinary error.
Recover only when the worker-pool API has a deliberate policy for turning panics into failures. Silently recovering can hide programming bugs and leave callers believing work completed normally.
Avoid accidental shared state
The pool controls how many functions execute at once; it does not automatically make fn thread-safe.
If fn modifies a shared map, buffer, client state, or another mutable object, that object still needs appropriate synchronization or an ownership model that prevents concurrent mutation.
Prefer returning values from workers over mutating unrelated shared state when possible.
Empty input is valid
When items is empty, the helper creates zero workers, sends zero jobs, closes the channel, and returns an empty result slice immediately.
Handling this naturally keeps callers from needing a special-case check before using the helper.
When not to use a worker pool
A worker pool is not automatically better than sequential code. For tiny, inexpensive operations, channel coordination and goroutine scheduling can cost more than the work itself.
It is also not a replacement for a durable job queue. If tasks must survive process restarts, be retried later, or run across multiple machines, use infrastructure designed for persistent background jobs.
The pattern is most useful when one Go process has a finite or streaming set of independent operations and needs an explicit concurrency ceiling.
Practical checklist
Before introducing bounded concurrency, identify the resource you are protecting and choose the limit based on that resource. Preserve input order only if callers need it. Decide error and cancellation semantics explicitly. Keep channel ownership clear, avoid unsynchronized shared state, and benchmark representative workloads before tuning worker counts.
A small fixed worker pool provides predictable pressure on downstream systems while retaining most of the throughput benefits that make Go concurrency useful.