Skip to content
article

The goroutine leak that took three weeks to find (the fix was four lines)

Context cancellation, an unbuffered channel, and why your timeout is not a cancellation

Danny Cho@danny_cho_la

July 28, 2026 · 3 min read

One of our Go services gained about 40 MB of memory a day. Nobody noticed, because we deploy every day and a deploy restarts every pod. Then came a holiday change freeze. Twelve days without a deploy. On day nine the pods started getting OOMKilled, one by one, during the busiest week of the year.

Finding it

The service already imported net/http/pprof on an internal port, which is the single best decision anyone made on that codebase.

go tool pprof http://localhost:6060/debug/pprof/goroutine
(pprof) top

About 180,000 goroutines, almost all of them parked on chan send in the same function. The goroutine count, not the heap, was the story. The heap was just the luggage they were carrying.

The code

func (s *Server) fetchPrice(ctx context.Context, sku string) (Price, error) {
	ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
	defer cancel()

	ch := make(chan result)
	go func() {
		p, err := s.pricing.Get(sku) // note: no ctx
		ch <- result{p, err}
	}()

	select {
	case r := <-ch:
		return r.price, r.err
	case <-ctx.Done():
		return Price{}, ctx.Err()
	}
}

Read it slowly. When the pricing service is slower than 300 ms, the select takes the ctx.Done() branch and the function returns. Nobody will ever read from ch again. The goroutine finishes its HTTP call eventually, tries to send on an unbuffered channel with no receiver, and blocks. Forever. Holding the response, the buffers, everything.

And because s.pricing.Get did not take the context, our "timeout" never cancelled the actual HTTP request either. The caller stopped waiting. The work kept going. A timeout in the caller is not a cancellation of the callee.

The four lines

	ch := make(chan result, 1) // the send can always complete, even if nobody reads
	go func() {
		p, err := s.pricing.Get(ctx, sku) // cancelled when we give up
		ch <- result{p, err}
	}()

And inside the client, the request gets the context:

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)

With a buffer of one, the goroutine can always drop its result and exit, and the garbage collector cleans up the channel. With the context passed down, the HTTP request is actually aborted when we stop caring about it.

Making sure it stays fixed

  • goleak in tests. go.uber.org/goleak, one line in each package: func TestMain(m *testing.M) { goleak.VerifyTestMain(m) }. Any test that leaves a goroutine behind fails. We found four more leaks the first day.
  • Alert on go_goroutines. The Prometheus Go client exports it for free. A count that only goes up between deploys is a leak, every time.
  • Every goroutine needs an exit. In review we now ask two questions for every go func(): who reads this channel, and what happens if they leave early.

My father ran a dry cleaner for 31 years and never once left a garment on the rack without a ticket. Every goroutine needs a ticket. Three weeks of my life for a lesson he could have taught me in one sentence.

Written by

Danny Cho

Staff eng in LA. Korean-American, grew up above my parents' dry cleaner in Koreatown. Distributed systems, Go, mentoring, Dodgers. Ask me about boring tech.

Get the best of Dynamo Geeks weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

11

Sign in to join the conversation.

Sign in to comment
Emeka Nwosu@emeka_golang
Unbuffered channel + select with timeout. The most common leak in Go, and I have shipped it myself at least twice 😂 Buffer of 1 is the cheapest insurance in the language
10 net votes
Ondřej Hrubý@rewrite_it_in_rust
in Rust the future is dropped when you stop polling it, no leak. Go is a language for people who think if err != nil is a personality 🦀
3 net votes
Danny Cho@danny_cho_la
Dropping the future is nice. A spawned task that waits on a channel nobody will ever read sits there just the same. Same bug, different syntax.
0 net votes
Karthik Subramaniam@karthi_rustacean
Small correction to both of you. In Rust, if the receiver is dropped, the send returns an error immediately, so that exact leak does not happen. But a spawned task that awaits something which never completes leaks exactly like this goroutine. Cancellation is a design problem in every language only.
5 net votes
Danny Cho@danny_cho_la
Fair correction, thank you. The channel half of my reply was wrong for Rust. The stuck task half stands.
4 net votes
Arjun Mehta@arjunmehta
We found the same in a payment status poller, 70k goroutines waiting on a bank API with no timeout at all. Do one thing: put a deadline on every outbound HTTP client, http.Client with Timeout set at minimum, even before you plumb ctx everywhere.
8 net votes
Kenji Tanabe@kenjitanabe
goleak in TestMain is the one most teams skip. We added it to a six year old service and 31 tests failed on day one. Every failure was real.
6 net votes
Oleksii Bondarenko@oleksii_kyiv
Read this in the corridor during air raid alert. 180k goroutines is more than people in my district. Buffered channel is my religion now )))
8 net votes
Tunde Adebayo@tunde_ade
omo 3 weeks for 4 lines. this is the most go thing ever. that memory graph going up like dollar to naira 😂
7 net votes
Mio Kobayashi@mio_rustacean
late, but karthi's version of the rust argument is correct imo. i challenged to write the same bug in rust and it was so hard to me. you have to work for it 🦀
9 net votes
Emeka Nwosu@emeka_golang
You had to work hard to write a bug. In Go we write it for free. That is called productivity my guy 😂
6 net votes