Why Does Your Go Worker Pool Leak Goroutines? Common Pitfalls and Fixes

This article dissects the frequent bugs that cause Go worker‑pool implementations to panic, deadlock, or leak goroutines, explains the four core principles for correct channel and WaitGroup usage, showcases five typical error patterns with concrete code, and provides a complete, production‑ready solution.

Tinker Programmer
Tinker Programmer
Tinker Programmer
Why Does Your Go Worker Pool Leak Goroutines? Common Pitfalls and Fixes

Problem Statement

Interviewers often ask you to implement a worker pool where N workers read tasks from a jobs channel, write results to a results channel, and exit gracefully after all tasks are done. Many candidates stumble not because they cannot write a for range loop, but because they misunderstand who should close which channel, where to place WaitGroup calls, and how to collect results without deadlock.

Four Core Principles ("Four Sentences")

Principle 1: The sender (the main goroutine) closes the jobs channel after all tasks have been sent.

Principle 2: Workers use a for range loop; the loop exits automatically when the channel is closed and fully drained.

Principle 3: WaitGroup is added before launching each worker and each worker calls Done via defer. The main goroutine waits after all workers are started.

Principle 4: The receiver (main) reads from results only after WaitGroup.Wait() returns, preventing deadlock.

Five Common Pitfalls (with concrete examples)

Pitfall 1: Closing Too Early – Panic

jobs := make(chan int, 5)
results := make(chan int, 5)

go func() {
    for job := range jobs {
        fmt.Printf("processing job %d
", job)
        time.Sleep(100 * time.Millisecond)
        results <- job * 2
    }
}()

jobs <- 1
jobs <- 2
jobs <- 3
close(jobs) // close before sending all tasks
jobs <- 4 // panic: send on closed channel
jobs <- 5

Issue: Sending on a closed channel triggers a panic. The channel must stay open until every task is dispatched.

Pitfall 2: Never Closing jobs – Goroutine Leak

jobs := make(chan int, 5)
go func() {
    for job := range jobs { // blocks forever because channel never closes
        fmt.Printf("processing job %d
", job)
        time.Sleep(100 * time.Millisecond)
    }
    fmt.Println("worker exited")
}()

jobs <- 1
jobs <- 2
jobs <- 3
fmt.Println("main: done")

Issue: Without closing jobs, workers block on the for range loop, causing goroutine leakage and eventual OOM.

Pitfall 3: Unbuffered results without Receiver – Deadlock

jobs := make(chan int, 5)
results := make(chan int) // unbuffered

go func() {
    for job := range jobs {
        fmt.Printf("processing job %d
", job)
        time.Sleep(100 * time.Millisecond)
        results <- job * 2 // blocks if main hasn't started receiving
    }
}()

jobs <- 1
jobs <- 2
jobs <- 3
close(jobs)
fmt.Println("main: all jobs sent")
for i := 0; i < 3; i++ {
    fmt.Println("result:", <-results)
}

Issue: With an unbuffered results, workers block on send until the main goroutine receives; if the main goroutine starts receiving too late, workers deadlock.

Pitfall 4: Adding WaitGroup Inside Goroutine – Race

var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
    go func(id int) {
        wg.Add(1) // wrong: may run after wg.Wait()
        defer wg.Done()
        // work ...
    }(i)
}
wg.Wait()

Issue: Adding to the WaitGroup after the goroutine starts can let Wait() return before the Add executes, leading to a missed Done and a hanging program.

Pitfall 5: Closing Channels Multiple Times – Panic

go func() {
    for job := range jobs {
        results <- job * 2
    }
    close(results) // wrong: each worker may close, causing panic
}()
// or main also closes results later → panic: close of closed channel

Issue: Only the sender should close a channel, and it must be closed exactly once.

Correct, Complete Worker‑Pool Implementation

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    const (
        numJobs    = 10
        numWorkers = 3
    )

    jobs := make(chan int, numJobs)
    results := make(chan int, numJobs)
    var wg sync.WaitGroup

    // launch workers
    for i := 1; i <= numWorkers; i++ {
        wg.Add(1) // main adds before launching
        go func(id int) {
            defer wg.Done()
            for job := range jobs {
                fmt.Printf("worker %d processing job %d
", id, job)
                time.Sleep(100 * time.Millisecond)
                results <- job * 2
            }
            fmt.Printf("worker %d: no more jobs, exiting
", id)
        }(i)
    }

    // send tasks
    for i := 1; i <= numJobs; i++ {
        jobs <- i
    }
    close(jobs) // sender closes jobs

    wg.Wait()          // wait for all workers to finish
    close(results)     // now safe to close results

    // collect results
    for r := range results {
        fmt.Println("result:", r)
    }

    fmt.Println("main: all done")
}

This version respects all four principles, avoids the five pitfalls, and reliably exits without leaks or deadlocks.

Responsibility Summary

wg.Add(1)

– main (before go) wg.Done() – each worker (via defer) close(jobs) – main after sending all tasks wg.Wait() – main, waits for workers close(results) – main after

Wait()

Immediate Actions for Readers

Run the correct code on your machine several times to see orderly shutdown and correct results.

Search your existing Go projects for make(chan and verify that every jobs channel is closed by the sender and that results is closed only once after all workers finish.

Rewrite any old coordination code using the "responsibility" checklist to spot hidden bugs.

Next up, the series will dive into five concrete ways a close operation can panic, clarifying the exact semantics of channel closing.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

concurrencygochannelwaitgroupgoroutine-leakworker-poolgo-interview
Tinker Programmer
Written by

Tinker Programmer

Solving problems with code, sharing practical tech insights, and leveling up together!

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.