I found the number 41,217 in a pprof dump last month and felt my stomach drop. That’s how many goroutines one of my services had piled up after nine days of uptime. Not 41 thousand requests in flight. 41 thousand goroutines, each one parked forever on a channel send that nobody was ever going to read. The service wasn’t even busy. It was quietly hoarding memory until the OOM killer restarted it every week, and I’d spent a month blaming those restarts on “probably a memory thing.”
So this is the post I wish I’d read earlier. It’s not a tour of every concurrency primitive Go owns. It’s the small set of golang concurrency patterns I actually ship in 2026, the leak I keep finding in other people’s code (and mine), and the change in Go 1.25 that deleted my least favorite boilerplate.
The leak I kept shipping
Here’s the bug, reduced to its embarrassing essence. I wanted a fetch with a timeout:
func fetchWithTimeout(url string) (string, error) {
ch := make(chan string)
go func() {
result := slowFetch(url) // sometimes takes 30 seconds
ch <- result // blocks until someone receives
}()
select {
case res := <-ch:
return res, nil
case <-time.After(2 * time.Second):
return "", errors.New("timed out")
}
}
It looks fine. It reads fine in review. But walk through the timeout path: fetchWithTimeout returns, the select is gone, and now nobody will ever receive from ch. The goroutine finishes its slow fetch, tries to send on an unbuffered channel, and blocks. Forever. It can’t be garbage collected because as far as the runtime knows it’s still doing work. Every timed-out request leaks exactly one goroutine, and on a bad day with a slow upstream, that’s thousands per hour.
The fix costs three characters:
ch := make(chan string, 1) // buffered: the send always completes
With a buffer of one, the goroutine sends its result whether anyone is listening or not, then exits and gets cleaned up. The abandoned result sits in the buffer until the channel itself is collected. That’s it. That was my OOM.
Decide who owns every channel
The deeper lesson took me longer to absorb: every channel needs an owner, and the owner is the only one allowed to close it. My rule of thumb comes from the Go team’s pipelines post on the Go blog, which is over a decade old now and still the best thing written on the subject.
The goroutine that writes to a channel closes it. Receivers never close. If several goroutines write to the same channel, none of them closes it; a coordinator does, after all the writers are done.
When I review Go now, the first thing I do with any channel is ask who closes it, and whether a send can ever happen after that. If the answer takes more than ten seconds to work out, the code is too clever. I’ve stopped feeling bad about replacing elegant channel choreography with a mutex and a slice. Channels are for moving ownership of data between goroutines. They’re not a badge of Go fluency, and a mutex is not an admission of defeat.
The boilerplate Go 1.25 deleted
For a decade, launching a batch of goroutines and waiting for them looked like this:
var wg sync.WaitGroup
for _, u := range urls {
wg.Add(1)
go func() {
defer wg.Done()
process(u)
}()
}
wg.Wait()
The Add(1) and defer Done() dance is pure ceremony, and it’s easy to get subtly wrong. Call Add inside the goroutine instead of before it and Wait can return before anything has started. I’ve done that. In a test. Which passed anyway, and that’s somehow worse.
Go 1.25 added WaitGroup.Go to the standard library, and the same code is now:
var wg sync.WaitGroup
for _, u := range urls {
wg.Go(func() { process(u) })
}
wg.Wait()
The counter management moved inside the method, where it can’t be botched. It’s a small change, but I feel it every single day. And since Go 1.22 gave each loop iteration its own variable, the old u := u capture trick is dead too. My muscle memory still types it sometimes, like reaching for a light switch in a house I moved out of.
How many goroutines is too many
Goroutines are cheap. Each one starts with a few kilobytes of stack, so a hundred thousand of them is not by itself a problem. What’s expensive is what each goroutine holds: a database connection, an open socket, a chunk of a file in memory. Spin up one goroutine per item on a 50,000 item slice and you haven’t built concurrency. You’ve built a stampede pointed at your own database.
So for any fan-out, I reach for errgroup with a limit:
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10) // at most 10 in flight
for _, id := range ids {
g.Go(func() error {
return fetchUser(ctx, id)
})
}
if err := g.Wait(); err != nil {
return err
}
Three things happen here that I used to hand-roll badly. SetLimit caps concurrent work at ten, so the database sees a polite queue instead of a mob. The first error cancels ctx, which tells every other in-flight call to stop wasting effort. And Wait hands me the first real error instead of a pile of maybes.
The cancellation part only works if fetchUser actually respects its context, which is a discipline of its own. I spent two years getting that wrong and wrote up the damage in my post on Go’s context package, so I won’t repeat it here.
Picking the limit is unglamorous. I start at the size of the downstream connection pool and adjust from real latency numbers. There’s no magic constant. Ten is right until it isn’t.
How I catch leaks before production does
Three tools, in the order I add them to a project.
The race detector comes first. go test -race runs in CI on every project I touch, no exceptions, even though it makes the suite slower. Every data race it has ever flagged for me was real, and about half of them lived in code I’d have sworn was fine.
Then goleak, a small library from Uber. One line in TestMain fails any test that exits with stray goroutines still running:
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
If I’d had this on the service from the intro, the leak would have died in a pull request instead of eating memory for a month. It catches the timeout-abandonment shape shockingly well, because tests time out fast and leave the evidence lying around.
Last, pprof in production. Most of the Go I write is backend services for client work, a few of which are on my work page, and every one of them exposes the pprof goroutine profile on an internal port. When something feels off, I curl /debug/pprof/goroutine?debug=1 and thirty seconds of reading tells me more than an hour of log spelunking. The dump groups goroutines by stack trace, so 41,217 parked goroutines show up as one stack with a horrifying number next to it. Nothing subtle to interpret. The bug points at itself.
What to actually do this week
If you run a Go service, do one thing: expose pprof on an internal-only port, pull a goroutine profile, write down the count, and pull it again tomorrow. Flat is fine. Growing is a leak, and the profile is already showing you the stack it’s stuck on. It took me nine days of uptime and an OOM’d service before I looked. You can skip that part.