How to Fix a Go Goroutine Leak in an HTTP Handler
A Go goroutine leak happens when a handler spawns a goroutine that has no way to exit if the client disconnects - the channel sits unread and the goroutine wedges forever. The fix is wiring context cancellation through every blocking goroutine.
Why goroutines leak in HTTP handlers
The classic pattern looks safe at a glance:
func workHandler(w http.ResponseWriter, r *http.Request) {
ch := make(chan string)
go func() {
ch <- slowJob() // 3 seconds of work
}()
result := <-ch
fmt.Fprintln(w, result)
}
If the client disconnects before slowJob finishes, nothing drains ch.
The goroutine blocks on ch <- forever - it is leaked. Under real traffic,
10,000 leaked goroutines push the process OOM. pprof and
runtime.NumGoroutine() confirm the count climbing.
Fix step 1: pass the request context into the worker
r.Context() is already cancelled when the HTTP connection closes. Pass it
into slowJob and add a select so the function returns the moment
cancellation fires:
func slowJob(ctx context.Context) (string, error) {
select {
case <-time.After(3 * time.Second): // normal path
return "done", nil
case <-ctx.Done(): // client disconnected or timeout
return "", ctx.Err()
}
}
The goroutine now exits immediately on cancellation instead of wedging on a channel write that nobody will ever read.
Fix step 2: add a hard timeout with context.WithTimeout
A disconnected client isn't the only failure mode - a slow job on a connected client still piles up goroutines. Wrap the request context with a deadline before spawning the goroutine:
func workHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
defer cancel()
type result struct {
val string
err error
}
resultCh := make(chan result, 1) // buffered - goroutine never blocks
go func() {
v, err := slowJob(ctx)
resultCh <- result{v, err}
}()
select {
case <-ctx.Done():
http.Error(w, "gateway timeout", http.StatusGatewayTimeout)
return
case res := <-resultCh:
if res.err != nil {
http.Error(w, "gateway timeout", http.StatusGatewayTimeout)
return
}
fmt.Fprintln(w, "result:", res.val)
}
}
Two key details: the channel is buffered (make(chan result, 1)) so
the goroutine can write and exit even if the handler already returned, and
defer cancel() frees the timer resources on every exit path.
Verify the fix
# Fire 100 cancelled requests (--max-time 0.1 disconnects before slowJob finishes)
for i in $(seq 1 100); do curl -s --max-time 0.1 http://localhost:8000/api/work; done
# Goroutine count should stay bounded (not grow by 100)
curl http://localhost:8000/_goroutines
Before the fix this count climbs by one per request. After the fix it stays flat - each goroutine exits as soon as its context is cancelled.
The general rule
Every goroutine that does a blocking operation (channel recv, network call,
time.Sleep) must select on a ctx.Done() channel. No exceptions.
context.WithTimeout / context.WithDeadline are the standard way to
add a bound; context.WithCancel covers explicit teardown.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Passing r.Context() into a goroutine worker and selecting on ctx.Done()
- Wrapping a request context with context.WithTimeout and deferring cancel()
- Buffering result channels so goroutines can exit after the handler returns
FAQ
What causes a goroutine leak in Go?
A goroutine leaks when it blocks indefinitely - usually on a channel send or receive that nobody will ever complete - and there is no cancellation path to make it exit. HTTP handlers that spawn goroutines without passing a context are the most common source.
How do I fix a Go goroutine leak in an HTTP handler?
Pass r.Context() into the goroutine's work function and add a select on ctx.Done() inside any blocking operation. Use context.WithTimeout to add a deadline, buffer the result channel so the goroutine can write and exit even after the handler returns, and always defer cancel().
How do I detect a goroutine leak in Go?
Expose runtime.NumGoroutine() on a debug endpoint and watch it climb under cancelled requests. pprof's goroutine profile (go tool pprof .../debug/pprof/goroutine) shows a stack trace for each stuck goroutine. The goleak package catches leaks in unit tests.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →