Skip to content

Golang Concurrency Patterns 2026: errgroup and Context I Actually Ship

Golang Concurrency Patterns 2026: errgroup and Context I Actually Ship

Confession: I spent an evening two weeks ago debugging a Go service where one goroutine was quietly panicking, the whole request hung for thirty seconds, and the panic never made it back to the caller. Nothing in the logs. Nothing in the traces. Just a slow response and a deployment I couldn’t undo fast enough.

The root cause was embarrassing. I had a sync.WaitGroup, three goroutines, and a helper function returning an error I was silently swallowing. Rookie mistake. I’ve been writing Go for years, and that’s exactly why I’m writing this post. After all that time, the concurrency patterns I actually reach for in 2026 aren’t the ones the tutorials open with. They’re the ones I’ve been burned into using.

Short version for the impatient: use errgroup unless you have a very specific reason not to. Pass context.Context down every call chain. Prefer bounded worker pools over goroutine confetti. If you want to know why, keep reading.

The sync.WaitGroup habit I finally broke

Every Go tutorial hands you a sync.WaitGroup on day one. It’s simple, it works, and it’s the first thing you learn. Which is why so much of my old code looked like this:

func fetchAll(urls []string) ([]Result, error) {
    var wg sync.WaitGroup
    results := make([]Result, len(urls))
    var firstErr error
    var mu sync.Mutex

    for i, u := range urls {
        wg.Add(1)
        go func(i int, u string) {
            defer wg.Done()
            r, err := fetch(u)
            if err != nil {
                mu.Lock()
                if firstErr == nil {
                    firstErr = err
                }
                mu.Unlock()
                return
            }
            results[i] = r
        }(i, u)
    }
    wg.Wait()
    return results, firstErr
}

Look at that mutex. Look at the manual firstErr bookkeeping. Look at how easy it is to forget the defer wg.Done() and hang the whole thing. And there’s no cancellation. If urls[0] fails, urls[1..N] keep running to completion anyway, burning CPU and network on results nobody will ever read.

I’ve written this pattern probably four hundred times. It works. And every single time I do, I’m reinventing wheels that other people have already welded together for me.

errgroup: WaitGroup with a brain

The golang.org/x/sync/errgroup package has been around since well before generics existed, and I still meet Go developers who’ve never used it. Same job as WaitGroup, but it returns the first error and gives you a shared context that cancels the rest of the group when anything fails. Here’s the same function rewritten:

func fetchAll(ctx context.Context, urls []string) ([]Result, error) {
    g, ctx := errgroup.WithContext(ctx)
    results := make([]Result, len(urls))

    for i, u := range urls {
        i, u := i, u
        g.Go(func() error {
            r, err := fetch(ctx, u)
            if err != nil {
                return err
            }
            results[i] = r
            return nil
        })
    }
    if err := g.Wait(); err != nil {
        return nil, err
    }
    return results, nil
}

No mutex. No firstErr variable. When one goroutine returns an error, ctx gets cancelled, and every other fetch(ctx, u) call unwinds if it respects context. g.Wait() returns the first error it saw.

I reread the errgroup docs once every six months just to remind myself the SetLimit method exists. It caps the number of goroutines the group will run in parallel, which is the exact thing I used to reinvent with semaphore channels. More on that in a second.

One catch worth knowing: g.Go doesn’t recover from panics. If a goroutine panics, it takes down the process. I actually prefer this behavior because I want to see the panic in my traces. But if you’re wrapping user-supplied code, wrap it with a defer recover() at the top of your goroutine.

Context cancellation: the wire that ties it together

I used to treat context.Context as the annoying first parameter I had to keep threading through my functions. Now I treat it as the single most important piece of Go’s concurrency model, because everything else composes around it.

The pattern I actually ship looks like this:

func handleRequest(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()

    result, err := doWork(ctx)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(result)
}

Two things I do religiously that I didn’t used to. First, every function that does anything blocking takes ctx context.Context as its first argument: database calls, HTTP requests, file reads, channel receives. If a function can’t be cancelled, that’s a code smell I want to see and fix. Second, I always defer cancel() right after context.WithTimeout or context.WithCancel. Even if the function returns normally. cancel() is idempotent, and forgetting it leaks resources in ways you won’t notice until the process has been up for a week.

The context package documentation says context values should be for request-scoped data that transits process or API boundaries. Which is the polite way of saying: don’t stuff a database connection into a context. I’ve seen that pattern in production. It always ends the same way.

Worker pools without the reinvention

The worker-pool pattern is where I’ve seen the most cargo-cult Go code. Everyone’s version looks slightly different, everyone’s version has a subtle bug, and half the time the answer is just errgroup.SetLimit.

Here’s the version I reach for when I need bounded parallelism over a stream of work:

func processBatch(ctx context.Context, items []Item) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(10) // at most 10 goroutines in flight

    for _, item := range items {
        item := item
        g.Go(func() error {
            return processItem(ctx, item)
        })
    }
    return g.Wait()
}

Ten lines. Bounded parallelism. First-error cancellation. Done. Compare that to the standard “channel of jobs, N worker goroutines, close the channel, wait for the workers” pattern, which is three times longer and easier to get wrong.

The one case where I still write the classic channel-based worker pool is when the work is unbounded and streaming, like a long-running service that reads items off a queue forever. errgroup is designed for batches with a known end. If your input is a stream, channels are still the right primitive.

Where I still reach for raw channels

Channels haven’t gone anywhere. I use them less than I used to, but the cases where they win are still real.

Fan-out, fan-in with backpressure looks something like this:

func pipeline(ctx context.Context, in <-chan Job) <-chan Result {
    out := make(chan Result)
    go func() {
        defer close(out)
        for job := range in {
            select {
            case out <- process(job):
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

Notice the select with ctx.Done(). That’s the part I used to forget. Without it, if the consumer stops reading out, the producer goroutine blocks forever, and you have a leak that only shows up in production memory graphs a week later.

Signaling between goroutines, single-value handoff, timeouts on receive: channels are still the right answer. It’s specifically the “coordinate N goroutines and wait for them all” case where errgroup has quietly retired most of my channel code.

If you’re new to Go and want the full mental model, the Effective Go doc still holds up in 2026. Rob Pike’s original Concurrency is not parallelism talk is also worth the twenty minutes it takes to watch.

The tests I now write for concurrency code

I used to skip tests for concurrent code because they’re annoying to write. Then I wrote a service where a race condition only showed up under production load, and I converted.

Two habits since. First, go test -race on every CI run. The race detector isn’t free, but it’s cheaper than the incident. Second, table-driven tests with a timeout. If a test on a channel-based function doesn’t have context.WithTimeout or t.Deadline(), it can hang your entire CI runner when a bug regresses.

I wrote up more of my Go habits in Go error handling in 2026, which pairs weirdly well with this post. A lot of the concurrency mistakes I’ve made were actually error-handling mistakes wearing a costume.

What I’d tell someone starting Go concurrency this week

Skip the WaitGroup phase. Learn context.Context first, then learn errgroup, and only reach for raw goroutines and channels when you have a specific reason those two don’t fit. The tutorial order isn’t the production order.

If you’re building something in Go and want a second set of eyes on the architecture, I take on that kind of work through my consulting page. Happy to look at a repo and tell you which patterns are load-bearing and which ones you can quietly delete.

One concrete thing to do this week: pick your biggest handler function, grep for sync.WaitGroup, and see if errgroup would let you delete a mutex and an error-tracking variable. Chances are, it will.