The bottleneck that saves your server
Your API suddenly handles a spike in traffic. Five hundred requests arrive in one second. You spin up a goroutine for each request. The server memory climbs. Database connection limits are hit. Requests time out. The system collapses under its own enthusiasm. You do not need more goroutines. You need a queue and a fixed number of workers.
A worker pool limits concurrency by design. Instead of launching a new goroutine for every task, you launch a fixed number of goroutines upfront. Those goroutines sit in a loop, pulling tasks from a shared channel. The channel acts as a buffer and a queue. When the channel fills up, new tasks wait. When a worker finishes, it grabs the next item. The pattern turns unbounded parallelism into controlled throughput.
Think of a car wash with three bays. Cars arrive and join a line. The three bays work continuously. When a bay finishes, the next car rolls in. The line keeps the cars organized. The bays keep the work steady. The car wash does not build a new bay for every car that shows up.
In Go, the channel is the line. The goroutines are the bays. sync.WaitGroup is the manager who waits until every bay is empty before locking the doors.
How bounded concurrency actually works
Channels in Go are typed pipes that synchronize goroutines. A buffered channel holds a fixed number of values. Sending to a full buffer blocks until a receiver frees a slot. Receiving from an empty buffer blocks until a sender fills it. This built-in backpressure is what makes worker pools safe. The producer cannot overwhelm the consumers.
The sync.WaitGroup tracks how many goroutines are still running. You call Add(n) before launching work. Each goroutine calls Done() when it finishes. Wait() blocks until the counter reaches zero. The counter is not a map or a slice. It is a lightweight atomic counter optimized for this exact pattern.
Go conventions keep this pattern readable across teams. Public names start with a capital letter. Private names start lowercase. There are no public or private keywords. The compiler enforces visibility at the package boundary. When you define a worker function, keep it lowercase unless another package needs to call it directly. Receiver names follow the same rule: one or two letters matching the type, like (w *Worker) Process(...), not (this *Worker).
Goroutines are cheap. Channels are not magic.
The minimal pool
Here is the simplest worker pool: three workers, a buffered task channel, and a wait group to keep main alive until the work finishes.
package main
import (
"fmt"
"sync"
"time"
)
// worker processes tasks from the jobs channel until it closes.
func worker(id int, jobs <-chan int, wg *sync.WaitGroup) {
defer wg.Done() // signal completion when this goroutine exits
for j := range jobs { // range blocks until channel closes
fmt.Printf("Worker %d started job %d\n", id, j)
time.Sleep(time.Second) // simulate CPU or I/O bound work
fmt.Printf("Worker %d finished job %d\n", id, j)
}
}
func main() {
const numWorkers = 3
jobs := make(chan int, 100) // buffer prevents sender blocking on first few sends
var wg sync.WaitGroup
for w := 1; w <= numWorkers; w++ {
wg.Add(1) // track one more goroutine before launching
go worker(w, jobs, &wg)
}
for j := 1; j <= 5; j++ {
jobs <- j // send tasks to the queue
}
close(jobs) // signal no more tasks are coming
wg.Wait() // block until all workers finish their current jobs
}
Walking through the runtime
The program starts by creating a channel with a capacity of one hundred. That buffer gives the main goroutine room to send five tasks without blocking, even though the workers have not started pulling yet. If the channel had zero capacity, the first send would block until a worker was ready to receive. Buffering smooths out bursty workloads.
Three goroutines launch. Each one calls wg.Add(1) before starting, so the wait group knows exactly how many goroutines to track. The defer wg.Done() ensures the counter decrements even if a worker panics. Deferring cleanup is standard Go practice. It keeps the exit path identical to the success path.
The for j := range jobs loop is the heart of the pattern. In Go, ranging over a channel blocks until a value arrives. When the channel closes, the loop exits cleanly. This is why close(jobs) is mandatory. Without it, the workers sit forever, waiting for a task that will never come. The compiler will not catch this. You only notice when the process refuses to terminate.
The main goroutine sends five integers, closes the channel, and calls wg.Wait(). The wait group blocks until all three workers have finished their last assigned job and returned. Only then does main exit.
Trust gofmt. Argue logic, not formatting.
Production-ready patterns
Real code rarely processes bare integers. You usually handle structs, make HTTP calls, or write to a database. You also need to handle errors and respect cancellation. Here is a pool that processes simulated API calls and collects results.
package main
import (
"context"
"fmt"
"sync"
"time"
)
// Task represents a unit of work with an identifier.
type Task struct {
ID int
Duration time.Duration
}
// Result holds the outcome of processing a task.
type Result struct {
TaskID int
Err error
}
// processTask simulates an external API call that might fail.
func processTask(ctx context.Context, t Task) Result {
select {
case <-time.After(t.Duration):
return Result{TaskID: t.ID}
case <-ctx.Done():
return Result{TaskID: t.ID, Err: ctx.Err()} // return context cancellation error
}
}
The processTask function checks ctx.Done() alongside the simulated work. If the timeout fires, the worker stops waiting and returns immediately. This prevents goroutine leaks when the parent request is cancelled. Context is plumbing. Run it through every long-lived call site.
Here is the pool manager and the main driver.
// workerPool runs a fixed number of workers pulling from jobs.
func workerPool(ctx context.Context, jobs <-chan Task, results chan<- Result, size int) {
var wg sync.WaitGroup
for i := 0; i < size; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for t := range jobs {
res := processTask(ctx, t)
results <- res // send result back to collector
}
}()
}
// close results channel when all workers finish
go func() {
wg.Wait()
close(results)
}()
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
jobs := make(chan Task, 50)
results := make(chan Result, 50)
workerPool(ctx, jobs, results, 3)
for i := 1; i <= 6; i++ {
jobs <- Task{ID: i, Duration: time.Duration(i) * time.Second}
}
close(jobs)
for r := range results {
if r.Err != nil {
fmt.Printf("Task %d failed: %v\n", r.TaskID, r.Err)
} else {
fmt.Printf("Task %d completed\n", r.TaskID)
}
}
}
The workerPool function launches the goroutines and manages the wait group internally. It uses an anonymous function to avoid capturing the loop variable i incorrectly. Go 1.22 fixed the loop variable capture bug, but writing explicit closures remains the safest habit. The pool also spawns a final goroutine to close the results channel once all workers exit. This keeps the main goroutine from blocking forever on the results range loop.
Error handling in Go is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You will see if err != nil { return err } everywhere. Do not fight it. Wrap the value or change the design.
Where things break
Worker pools look simple until they break. The most common failure is forgetting to close the task channel. The workers range over it indefinitely. The main goroutine exits, but the workers keep running. The program hangs or leaks memory. Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path.
Sending on a closed channel causes an immediate panic. The runtime prints panic: send on closed channel. This happens when a worker tries to push a result back to a channel that the collector already closed, or when you accidentally close the jobs channel twice. Always close a channel exactly once, and only from the sender side.
Forgetting the wait group means main exits before the workers finish. The program terminates, killing all background goroutines. You lose results and leave resources open. The compiler gives no warning. The runtime just stops.
Unbuffered channels can cause deadlocks if the sender and receiver are not carefully synchronized. If you send to an unbuffered channel and no worker is ready, the sender blocks. If the sender is the only goroutine left, the program deadlocks. The runtime detects this and panics with fatal error: all goroutines are asleep - deadlock!. Buffer the channel or ensure a receiver is always ready.
The compiler rejects programs with type mismatches early. If you pass a string where an int is expected, you get cannot use x (untyped int constant) as string value in argument. Forget to import a package and you get undefined: pkg. Forget to use one and you get imported and not used. These errors are helpful. Runtime panics are not.
The worst goroutine bug is the one that never logs.
Choosing the right concurrency model
Concurrency is a tool, not a default. Pick the pattern that matches your workload.
Use a worker pool when you need bounded concurrency to protect a downstream service like a database or external API. Use unbounded goroutines when each task is completely independent, short-lived, and the system has enough memory to handle spikes. Use a single goroutine plus a channel when one task feeds another in a pipeline and you want to separate concerns without parallelism. Use plain sequential code when you do not need concurrency: the simplest thing that works is usually the right thing.
Pick the pool size based on the bottleneck. CPU-bound work matches the number of logical cores. I/O-bound work scales higher, often ten to fifty times the core count. Measure with pprof, not guesswork. Do not pass a *string to workers. Strings are already cheap to pass by value. Accept interfaces, return structs. Keep the boundaries clean.