Worker Pool Pattern in Go

Share
Worker Pool Pattern in Go

I'll be honest with you. The first time someone told me about the Worker Pool pattern, my reaction was: "Why would I limit myself to 5 goroutines when I can just fire up 50?"

And look — that's a fair question. Go makes concurrency so easy that spinning up 50 goroutines feels like the obvious move. And for a while, it works great. Until it doesn't.

This post is about what happens when it doesn't, and why the "slower" approach might actually save your butt in production.

The "Just Spawn 50 Goroutines" Approach

Let's start with the thing we've all done. You have 50 users to process, so you spin up 50 goroutines:

for _, user := range users {
    wg.Add(1)
    go func(u User) {
        defer wg.Done()
        processUser(u)
    }(user)
}
wg.Wait()

This is clean. This is simple. And honestly? For 50 tasks it'll probably be faster than a worker pool. All 50 goroutines run at once, they finish whenever they finish, done.

⚠️
Where It Falls Apart
Imagine each of those goroutines is hitting an external API. Now you've got 50 HTTP requests flying out at the same time. The API on the other end starts rate-limiting you. Or worse — some requests hang for 30 seconds. Or they fail silently. And you have zero control over any of it. No way to cancel them. No way to set a timeout. No way to say "hey, something's wrong, let's stop." You just… wait and hope.

And then there's scale. 50 is fine. 500 gets sketchy. 10,000 and you're basically DDoS-ing yourself from the inside. Each goroutine eats memory, pressures the scheduler, and if they're all doing I/O, your server starts to sweat.

Let's now use the Worker Pool

The idea is almost too simple: instead of one goroutine per task, you start a fixed number of workers. Let's say 5. They sit there, waiting for work. You feed tasks into a channel, and each worker grabs one, does the job, then comes back for more.

Think of it like a restaurant kitchen. You don't hire 50 cooks for 50 orders. You've got 5 cooks, and orders come in on tickets. Each cook grabs the next ticket when they're free.

Here's a basic worker pool with an unbuffered channel. Unbuffered means there's no "waiting room" — when you send a task, a worker has to be ready to grab it right then and there.

package main

import (
    "fmt"
    "math/rand/v2"
    "sync"
    "time"
)

type User struct {
    username string
    age      int
}

func worker(id int, jobs <-chan User, wg *sync.WaitGroup) {
    defer wg.Done()

    for user := range jobs {
        fmt.Printf(
        "Worker %d processing %s (age %d)\n",
        id, user.username, user.age
        )
        
        time.Sleep(200 * time.Millisecond)
    }

    fmt.Printf("Worker %d: channel closed, heading home\n", id)
}

func main() {
    jobs := make(chan User)

    users := make([]User, 50)
    for i := range 50 {
        users[i] = User{
            username: fmt.Sprintf("user_%d", i),
            age:      rand.IntN(40),
        }
    }

    // Step 1: start workers BEFORE sending jobs
    var wg sync.WaitGroup
    numWorkers := 5

    for i := 1; i <= numWorkers; i++ {
        wg.Add(1)
        go worker(i, jobs, &wg)
    }

    // Step 2: feed jobs in a separate goroutine
    go func() {
        for _, user := range users {
            jobs <- user
        }
        close(jobs) // signal that there's no more work
    }()

    // Step 3: wait for everyone to wrap up
    wg.Wait()
    fmt.Println("All done!")
}

Three Things That Will Bite You

I learned all of these the hard way, so you don't have to.

1. Start workers before sending jobs

If you send jobs before any workers exist, nobody's listening on the channel. Your program freezes. The terminal just sits there. You sit there. Nobody wins.

NOPE
❌ Send jobs to channel first
❌ Nobody's listening yet
❌ Deadlock — program hangs forever
YES
✅ Start workers first
✅ They're ready and listening
✅ Then send jobs — smooth sailing

2. Always close the channel

That range jobs inside the worker? It only stops when the channel closes. If you forget close(jobs), your workers will sit there waiting for work that's never coming. Like waiting for a bus that got cancelled. Deadlock, again.

3. Send jobs in a goroutine

With an unbuffered channel, every send blocks until someone receives. If you send from main, you'll block the main thread and nothing else runs. Wrap it in a goroutine so it runs alongside the workers.

OK But… Isn't the Worker Pool Slower?

Yes. Let's not pretend otherwise.

If you have 50 tasks and you fire 50 goroutines, they all run at once. With a worker pool of 5, you're processing 5 at a time. That's literally 10x fewer concurrent operations. In raw throughput, the 50 goroutines win.

So why on earth would you choose the slower option?

Because speed means nothing if half your requests fail and you have no idea why. The worker pool isn't about being fast — it's about being in control when things go sideways. And in my experience, things always go sideways eventually.

And this is where the magic really is. The worker pool gives you a select statement inside each worker. That select is where everything interesting happens.

The Power of: select + Timeout

This is the part that changed my mind about the whole pattern. With 50 loose goroutines, if one task hangs for 60 seconds… you just wait. There's nothing you can do about it. With a worker pool, each worker has a select that can listen for multiple signals at once:

func worker(ctx context.Context, id int, jobs <-chan Task, wg *sync.WaitGroup) {
    defer wg.Done()

    for {
        select {
        case <-ctx.Done():
            fmt.Printf("Worker %d: got cancel signal, stopping\n", id)
            return

        case job, ok := <-jobs:
            if !ok {
                return
            }
            process(job)
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    // ... start workers with ctx, send jobs, wait
}

Look at that select. It's doing two things at once: "is there a new job?" and "did someone tell me to stop?" If you set a 5-second timeout with context.WithTimeout, the moment those 5 seconds are up, every worker stops. Cleanly. No hanging, no zombie goroutines, no leaked resources.

💡
That's the point
With 50 raw goroutines, you can't do this. There's no select, no central point of control. If something goes wrong — a slow API, a network timeout, an unexpected error — you're just stuck watching. The worker pool gives you a place to put that logic. You trade some speed for the ability to actually handle problems when they show up.

You can also use context.WithCancel if you want manual control. Maybe you detect a bad error and want to abort everything immediately:

ctx, cancel := context.WithCancel(context.Background())

if err != nil {
    cancel() // boom — all workers stop
}

Try doing that with 50 loose goroutines. You'll end up building your own cancellation system with shared variables and mutexes and… honestly at that point you've just reinvented a worse version of a worker pool.


Written with real mistakes, real deadlocks, and eventually… real understanding.