Three Go Concurrency Mistakes I See in Almost Every Worker Pool
Go makes concurrency feel easy. Goroutines are cheap, channels are built into the language, and the standard library gives you everything you need to spin up a worker pool in under 30 lines. That ease is genuinely valuable — but it also means bugs sneak in quietly. The program compiles, the tests pass, and the mistake only surfaces under load or after hours of runtime.
After working on high-throughput backend systems in Go, I keep seeing the same three mistakes. None of them are obscure. All of them are fixable in minutes once you know what to look for.
Mistake 1: Goroutine leaks from channels that never close
Here is a worker pool that looks completely reasonable:
func startWorkers(jobs <-chan Job) {
for i := 0; i < 10; i++ {
go func() {
for job := range jobs {
process(job)
}
}()
}
}
Each worker ranges over the jobs channel and processes whatever comes in. Clean, idiomatic Go. The problem: if jobs is never closed, every one of those goroutines blocks forever.
for range on a channel blocks until the channel is closed or a value arrives. If the producer stops sending but never calls close(jobs), your 10 workers sit there permanently — holding memory, holding stack space, invisible to your metrics unless you’re explicitly tracking goroutine count.
In a long-running service, this compounds. Every time you restart the worker pool (say, on a config reload or a new batch) without draining the old one, you accumulate leaked goroutines.
The fix: always close the jobs channel when the producer is done, and own that responsibility clearly.
func runBatch(jobList []Job) {
jobs := make(chan Job, len(jobList))
// Producer owns the channel and closes it when done
go func() {
defer close(jobs) // guaranteed even if we panic
for _, job := range jobList {
jobs <- job
}
}() var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
process(job)
}
}()
} wg.Wait()
}
Two things worth noting here. First, defer close(jobs) in the producer goroutine means the channel closes even if the producer exits early or panics while sending jobs. Second, the sync.WaitGroup gives us a clean join point — wg.Wait() blocks until every worker has finished draining.
Quick diagnostic: add runtime.NumGoroutine() to your health endpoint. If that number grows over the lifetime of your service and never comes back down, you have a leak.
Mistake 2: WaitGroup counter manipulation in the wrong place
sync.WaitGroup has one sharp edge: wg.Add must be called before the goroutine starts, not inside it.
This version has a race condition:
var wg sync.WaitGroup
for _, job := range jobs {
go func(j Job) {
wg.Add(1) // ❌ too late
defer wg.Done()
process(j)
}(job)
}wg.Wait()
If the loop spawns goroutines quickly and the scheduler runs slowly, wg.Wait() can be reached before any goroutine has called wg.Add(1). The counter is zero, Wait() returns immediately, and your main function exits while workers are still running — silently dropping work.
This is a classic race condition. It won’t always reproduce, which makes it worse.
The fix is simple: call wg.Add in the loop body, before launching the goroutine.
var wg sync.WaitGroup
for _, job := range jobs {
wg.Add(1) // ✅ happens-before the goroutine starts
go func(j Job) {
defer wg.Done()
process(j)
}(job)
}wg.Wait()
Now the counter increment is guaranteed to happen before wg.Wait() could possibly observe a zero. The goroutine carries the Done() responsibility via defer, so it fires even on panic.
A secondary pattern worth knowing: if you know the count upfront (say, you’re launching exactly N workers), call wg.Add(N) once before the loop. That’s even cleaner and harder to get wrong.
const numWorkers = 10
var wg sync.WaitGroup
wg.Add(numWorkers) // add all at once
for i := 0; i < numWorkers; i++ {
go func() {
defer wg.Done()
for job := range jobs {
process(job)
}
}()
}wg.Wait()
Mistake 3: Unidirectional channel types ignored on function boundaries
Go lets you declare channels as send-only (chan<- Job) or receive-only (<-chan Job). Most codebases I’ve seen don’t use this consistently — functions just accept chan Job everywhere. This is technically fine, but it loses something real: the compiler can no longer tell you when your data flow is wrong.
Here is a worker pool where the direction confusion causes a real bug:
func spawnWorkers(jobs chan Job, results chan Result) {
for i := 0; i < 10; i++ {
go func() {
for job := range jobs {
result := process(job)
jobs <- result.nextJob // ❌ worker feeding back into the input channel
results <- result
}
}()
}
}
A worker accidentally wrote back into jobs instead of results. With bidirectional channels, this compiles without complaint. With directional types, it fails at compile time:
func spawnWorkers(jobs <-chan Job, results chan<- Result) {
for i := 0; i < 10; i++ {
go func() {
for job := range jobs {
result := process(job)
jobs <- result.nextJob // ✅ compile error: cannot send to receive-only channel
results <- result
}
}()
}
}
The directional types act as documentation that’s enforced by the compiler. The producer only sees chan<- Job. The consumer only sees <-chan Job. Neither can accidentally do the wrong thing.
A complete worker pool using this pattern correctly:
func produce(jobs chan<- Job, jobList []Job) {
defer close(jobs)
for _, job := range jobList {
jobs <- job
}
}
func consume(jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
results <- process(job)
}
}func run(jobList []Job) []Result {
jobs := make(chan Job, 100)
results := make(chan Result, 100) var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go consume(jobs, results, &wg)
} go produce(jobs, jobList) // Close results once all workers are done
go func() {
wg.Wait()
close(results)
}() var out []Result
for result := range results {
out = append(out, result)
}
return out
}
Every function now only sees what it’s supposed to see. The compiler enforces the data flow. And the pattern is easy to read: produce feeds in, consume drains, the closer goroutine shuts down results when all workers finish.
Summary
Channel never closed — What goes wrong: goroutines leak, memory grows. Fix: the producer owns close(ch), always via defer.
wg.Add inside the goroutine — What goes wrong: work silently dropped, races at shutdown. Fix: call wg.Add before go func().
Bidirectional channels everywhere — What goes wrong: wrong-direction sends compile silently. Fix: use chan<- and <-chan at function boundaries.
None of these require clever solutions. They require habit. Once you internalize that the producer owns close, that Add precedes launch, and that channel direction is free documentation, they stop appearing.
Go 1.25’s go vet includes a WaitGroup analyzer that flags mistake 2 when Add is called from inside a goroutine. On older Go versions, test the behavior directly by asserting that all launched work completes; go test -race is not reliable for this case because WaitGroup uses internal synchronization. For mistake 1, runtime.NumGoroutine() in a test that runs your pool repeatedly and checks for growth is the simplest approach. Mistake 3 is pure compiler enforcement — no test needed once the types are right.
If this was useful, the next post in this series covers context.Context propagation in worker pools — specifically what happens when you need to cancel in-flight jobs cleanly without leaking the workers themselves.
