The bottleneck that breaks your server
Your application receives a burst of one hundred requests. Each request needs to fetch data from a third-party API that takes two seconds to respond. If you spawn a goroutine for every request, you open one hundred concurrent connections. The external API hits its rate limit, rejects half the requests, and your server spends the next thirty seconds retrying failed calls. You need a way to process many tasks but only run a fixed number at once. That is the worker pool.
What a worker pool actually does
A worker pool is a queueing system that decouples task creation from task execution. Tasks arrive and sit in a line. A fixed set of workers pulls them off one by one. The pool caps concurrency regardless of how many tasks arrive.
Think of a coffee shop with three baristas and a ticket machine. Customers drop tickets into the machine and wait. The machine holds a limited number of tickets before it stops accepting new ones. The baristas grab tickets as they finish their current drink. They never stop working unless the machine is empty. When the shop closes, the manager tells the baristas to stop, and they hand out the finished drinks to the waiting customers. The machine is your jobs channel. The baristas are your goroutines. The finished drinks are your results channel.
The pattern shines when you face external rate limits, expensive database queries, or CPU-heavy transformations. It keeps your memory footprint predictable and prevents you from overwhelming downstream systems. Pools turn unpredictable bursts into steady, manageable throughput.
The minimal working pool
Here is the simplest pool that actually runs. It spawns four workers, feeds them ten integers, and prints the doubled results.
// worker pulls jobs from the input channel, processes them, and sends results downstream.
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done() // signal completion when this goroutine exits
for j := range jobs { // block until a job arrives or the channel closes
results <- j * 2 // simulate work by doubling the value
}
}
The main function wires the pieces together. It creates the channels, launches the workers, feeds the jobs, and collects the output.
func main() {
const numWorkers = 4
jobs := make(chan int, 100) // buffer to prevent the sender from blocking immediately
results := make(chan int, 100)
var wg sync.WaitGroup
for w := 1; w <= numWorkers; w++ {
wg.Add(1) // track one more active worker
go worker(w, jobs, results, &wg)
}
go func() {
for i := 1; i <= 10; i++ {
jobs <- i
}
close(jobs) // tell workers no more jobs are coming
}()
go func() {
wg.Wait() // wait until all workers finish
close(results) // tell the main loop to stop reading
}()
for r := range results {
fmt.Println(r)
}
}
Run this and you get ten lines of output. The order might vary because goroutines schedule independently, but every job gets processed exactly once. Keep your worker count aligned with your CPU cores or external rate limits.
How the pieces coordinate at runtime
Execution starts in main. The program creates two buffered channels and a sync.WaitGroup. It then launches four goroutines. Each goroutine immediately hits the range jobs line and blocks because the channel is empty. Nothing happens yet.
The main goroutine spawns a sender. It pushes ten integers into the jobs channel. The buffer absorbs them without blocking. The four workers wake up, each grabbing one integer. They compute the result and push it into the results channel. As soon as a worker finishes, it loops back to range jobs and grabs the next available integer. This continues until all ten jobs are consumed.
The sender finishes its loop and calls close(jobs). The workers see the closed channel on their next iteration. The range loop exits. Each worker calls wg.Done() via the deferred statement. The closer goroutine, which has been waiting on wg.Wait(), finally unblocks. It calls close(results). The main loop, which has been ranging over results, sees the closure and exits cleanly. The program terminates.
The buffer size matters. If you set the jobs channel to zero, the sender blocks until a worker is ready to receive. If you set it too high, you might queue thousands of tasks in memory before any worker starts processing. Pick a buffer that matches your expected burst size, not your theoretical maximum. Channels are cheap, but unbounded queues are not.
A realistic HTTP handler example
Real code rarely doubles integers. It usually fetches data, transforms it, and returns a response. Here is how the pattern adapts to an HTTP endpoint that processes a batch of image URLs.
// ProcessBatch accepts a context and a slice of URLs, returning processed results.
func ProcessBatch(ctx context.Context, urls []string, workerCount int) ([]Result, error) {
jobs := make(chan string, len(urls))
results := make(chan Result, len(urls))
var wg sync.WaitGroup
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for url := range jobs {
res, err := fetchAndResize(ctx, url)
results <- Result{URL: url, Data: res, Err: err}
}
}()
}
go func() {
for _, u := range urls {
select {
case jobs <- u:
case <-ctx.Done():
return
}
}
close(jobs)
}()
go func() {
wg.Wait()
close(results)
}()
var out []Result
for r := range results {
out = append(out, r)
}
return out, nil
}
The context.Context parameter goes first, following Go convention. The sender uses select to respect cancellation. If the client disconnects, ctx.Done() fires, the sender stops pushing jobs, and the workers drain what they have before exiting. The receiver collects everything into a slice. You would typically attach this to an HTTP handler that reads a JSON array, calls ProcessBatch, and streams the response.
Error handling stays explicit. Go does not swallow failures. If fetchAndResize returns an error, the worker still sends a Result struct containing that error. The caller inspects each result and decides whether to retry or report failure. This keeps the happy path and the unhappy path visible in the same flow. Run gofmt on this file before committing. The community expects consistent indentation and spacing, and fighting the formatter wastes time that could go toward fixing actual bugs.
Where things go sideways
Worker pools introduce coordination complexity. The most common failure is a goroutine leak. A leak happens when a worker blocks on a channel that never gets closed. If you forget to close the jobs channel, the workers sit in range jobs forever. The program hangs. If you forget to close the results channel, the main loop hangs. Always have a cancellation path. Close channels exactly once.
Another trap is sending on a closed channel. If you accidentally call close(jobs) twice, or if a worker tries to push to results after the closer goroutine has already shut it down, the runtime panics with panic: send on closed channel. The stack trace points directly to the offending line. Check your channel lifecycle before you deploy.
Deadlocks appear when you mix unbuffered channels with circular dependencies. If the sender waits for the worker to receive, and the worker waits for the sender to finish, nothing moves. The runtime detects this and aborts with fatal error: all goroutines are asleep - deadlock!. Use buffered channels for the job queue, or ensure at least one side can proceed without blocking the other.
The compiler catches type mismatches early. If you try to send a string into an integer channel, you get cannot use "hello" (untyped string constant) as int value in send. If you forget to import sync, you get undefined: sync. Trust the compiler. It saves you from runtime surprises. The worst goroutine bug is the one that never logs.
When to reach for a pool
Concurrency tools solve specific problems. Pick the right one for the shape of your workload.
Use a worker pool when you need bounded concurrency to protect a downstream service.
Use a single goroutine plus a channel when one task feeds another in a pipeline.
Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.
Use a fan-out pattern with unbuffered channels when tasks are completely independent and you want maximum throughput without rate limits.
Use sync.WaitGroup alone when you just need to wait for a fixed set of goroutines to finish without passing data between them.