Say you have 1,000 letters to stamp and mail. Hire 1,000 people and they fight over the one stamp. Five people working steadily through the pile get more done.
That second setup — a small, fixed team pulling from a shared queue — is a worker pool. We’ll build one in Go, using one real task throughout: checking whether a list of URLs is up.
First: what is a goroutine?
go doSomething() starts doSomething in the background and moves on immediately. That background task is a goroutine.11. A Tour of Go: Goroutines.
go checkURL("https://example.com") // starts in the background
go checkURL("https://google.com") // starts right away too
// both are now running at the same time
Having several things in flight at once is concurrency. Goroutines make it easy, which is also what makes them easy to misuse.
The obvious first attempt
You have 1,000 URLs. Goroutines look like the perfect tool:
for _, url := range urls {
go checkURL(url)
}
This starts 1,000 goroutines at once, all hitting the network together. It compiles, it runs, and then requests start timing out.
Goroutines are cheap, not free.22. A goroutine starts with a stack of a few kilobytes, so thousands of them are fine; see the Go FAQ. The shared resources run out first. The real limit is whatever they share — here, the network. Launch too many and you get:
- Slow responses, because every request competes for the same bandwidth
- Timeouts and dropped connections as the network saturates
- A program that’s hard to control or reason about
What you want is concurrency with a ceiling: at most, say, 5 things running at once, however many tasks there are. That’s the worker pool.
queued 24 · in flight 0 · done 0 · timed out 0 · 0.0s
How the worker pool works
Picture a conveyor belt with a few workers beside it. Jobs go onto the belt; each free worker takes the next one, does it, and comes back. When every worker is busy, jobs wait on the belt.

With 3 workers and 4 URLs queued, 3 run and the fourth waits. When a worker frees up, it takes the fourth. You get speed and control.
The queue is a channel
The belt in our pool is a channel. A channel is a tube: you send values in one end and receive them from the other.33. A Tour of Go: Channels.

Many goroutines can send to and receive from the same channel safely, with no locks. The main program sends URLs in; workers take them out.
That safety covers the channel itself. Anything else the workers share, like a results map, still needs a mutex — or better, send results back on a second channel.44. The Go maxim: “Do not communicate by sharing memory; instead, share memory by communicating.” See Share Memory By Communicating, and run your tests with -race to catch what you miss (race detector).
Building it, piece by piece
Step 1: define the struct
type Pool struct {
numOfWorkers int
jobs chan string
wg sync.WaitGroup
}
numOfWorkersis the ceiling. You choose it.jobsis the channel: the belt between the main program and the workers.wgis async.WaitGroup, a counter that goes up when a worker starts and down when it finishes. At zero, everyone is done.
Step 2: create the pool
func NewPool(numberOfWorkers int) *Pool {
return &Pool{
numOfWorkers: numberOfWorkers,
jobs: make(chan string, 10),
}
}
make(chan string, 10) gives the channel a buffer of 10: you can queue 10 jobs before a worker has to take one.55. A Tour of Go: Buffered Channels. Think of a waiting room with 10 seats; once it’s full, the next arrival waits at the door.
Step 3: write the worker
This is where the pattern lives.
func (p *Pool) worker() {
for job := range p.jobs {
result := checkURL(job)
fmt.Println(result)
}
}
for job := range p.jobs waits for a job, handles it, and loops. Once the channel is closed and empty, the loop ends and the worker exits.66. A Tour of Go: Range and Close. Only the sender closes a channel; receivers never need to.
start
│
▼
wait for job ◄──────────────────────────┐
│ │
│ job arrives │
▼ │
do the work │
│ │
▼ │
handle result ───────────────────────────┘
│
│ channel closed AND empty
▼
done, exit
Step 4: start the workers
func (p *Pool) Start() {
for range p.numOfWorkers {
p.wg.Go(func() {
p.worker()
})
}
}
p.wg.Go starts a goroutine and counts it in the WaitGroup in one call.77. WaitGroup.Go arrived in Go 1.25. Before that you’d write wg.Add(1), then go func() { defer wg.Done(); p.worker() }(). Do that numOfWorkers times and you have that many workers waiting for jobs.88. for range p.numOfWorkers ranges over an integer, which needs Go 1.22 or later.
Step 5: send jobs in
func (p *Pool) Submit(job string) {
p.jobs <- job
}
p.jobs <- job sends a URL into the channel. If all 10 slots are full, Submit waits for a worker to free one. That’s a feature: it stops you queueing unlimited work ahead of the workers.
Step 6: stop cleanly
Most examples skip this. Don’t.
func (p *Pool) Stop(ctx context.Context) error {
close(p.jobs)
done := make(chan struct{})
go func() {
p.wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return fmt.Errorf("pool stop: %w", ctx.Err())
}
}
close(p.jobs)says nothing more is coming. Workers drain the queue, theirrangeloops end, and they exit.p.wg.Wait()blocks until every worker has finished.- The
selectbounds that wait with your context’s timeout. Finished in time, you getnil; a URL that never responds gets you an error instead of a hang.99. Returning early doesn’t stop the workers; they keep running in the background. To cancel in-flight requests too, pass the context down and build each request withhttp.NewRequestWithContext.
close(jobs)
│
▼
workers finish current job
drain remaining jobs in queue
workers exit
│
▼
wg.Wait() returns (counter = 0)
│
▼
done ✓
The full working example
package main
import (
"context"
"fmt"
"net/http"
"sync"
"time"
)
type Pool struct {
numOfWorkers int
jobs chan string
wg sync.WaitGroup
}
func NewPool(numberOfWorkers int) *Pool {
return &Pool{
numOfWorkers: numberOfWorkers,
jobs: make(chan string, 10),
}
}
func (p *Pool) Start() {
for range p.numOfWorkers {
p.wg.Go(func() {
p.worker()
})
}
}
func (p *Pool) Submit(job string) {
p.jobs <- job
}
func (p *Pool) Stop(ctx context.Context) error {
close(p.jobs)
done := make(chan struct{})
go func() {
p.wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return fmt.Errorf("pool stop: %w", ctx.Err())
}
}
func (p *Pool) worker() {
for url := range p.jobs {
status := checkURL(url)
fmt.Printf("%s → %s\n", url, status)
}
}
func checkURL(url string) string {
resp, err := http.Get(url)
if err != nil {
return "DOWN"
}
defer resp.Body.Close()
if resp.StatusCode == 200 {
return "UP"
}
return fmt.Sprintf("UNKNOWN (%d)", resp.StatusCode)
}
func main() {
urls := []string{
"https://example.com",
"https://google.com",
"https://github.com",
"https://definitely-not-a-real-site-xyz.com",
"https://go.dev",
}
pool := NewPool(3)
pool.Start()
for _, url := range urls {
pool.Submit(url)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := pool.Stop(ctx); err != nil {
fmt.Printf("shutdown error: %v\n", err)
}
}
Run it and three URLs resolve almost together, then the last two. That stagger is the pool at work: three workers, never more than three requests at once.1010. http.Get uses http.DefaultClient, which has no timeout, so one slow server can hold a worker indefinitely. In real code, use an http.Client with Timeout set.
Three mistakes people make (including me)
1. Forgetting to close the channel
Without close(p.jobs), workers wait in their range loop forever and wg.Wait() never returns. The program hangs on shutdown, with no error to tell you why.
Always close the channel once you’re done submitting.
2. Closing the channel while still sending to it
This one at least panics:
panic: send on closed channel
It happens when Stop closes the channel while something is still calling Submit. Stop whatever feeds the pool before you close it:1111. The Go blog’s Pipelines and cancellation covers who closes what, and when, in more depth.
WRONG order: RIGHT order:
close(jobs) stop the feeder first
submit(job) → PANIC close(jobs)
wg.Wait()
3. Expecting the buffer to add speed
The buffer doesn’t change how many jobs run at once; numOfWorkers does. With a buffer of 0, workers still run in parallel — Submit just waits until a worker is free to take the job.
A buffer lets the submitting loop get a few jobs ahead, which smooths out bursts. Keep it small: a huge buffer mostly hides how far behind the workers are.
When to reach for a worker pool
Use one when:
- You have a list of tasks: URLs, files, records, messages
- You want a hard limit on how many run at once
- The tasks are independent: task 3 doesn’t need task 2’s result
- You need a clean shutdown that finishes the work in progress
For a handful of tasks, launch goroutines directly. The pool earns its keep at hundreds or thousands.1212. If you also want the first error back, errgroup.Group.SetLimit gives you a bounded group of goroutines without writing the pool yourself.
Further reading
- Go sync package docs
- A Tour of Go: Concurrency — interactive, about 10 minutes
- Concurrency in Go by Katherine Cox-Buday — chapters 3 and 4 if you want to go deeper