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.

Backend Engineergoconcurrencycontext

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

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

Return the Correct HTTP Status CodeBackend projectRepair Broken API PaginationBackend projectAdd CORS Middleware to an Express APIBackend projectBackend roadmapStep by step to hiredBackend interview questionsSTAR answersAll Backend projectsProjects hub

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 →