Mesrai
Back to blog
// essayTechnical Deep Dive

Goroutine Leak in a Go HTTP Handler: Live Demo

Real PR catch: `go fetchAndProcess(...)` without context propagation — every cancelled request leaks a goroutine. Context+timeout fix.

Mesrai TeamJuly 27, 20268 min read

An import endpoint that spawns a goroutine to do background work and returns immediately. Looks idiomatic Go. Under churn (many cancelled requests), the worker count grows monotonically — goroutine leak, memory leak, eventual OOM.

The vulnerable diff

gohandlers/import.go
// handlers/import.go
func handleImport(w http.ResponseWriter, r *http.Request) {
    url := r.URL.Query().Get("url")
    // BUG: goroutine not bound to request context
    go fetchAndProcess(url)
    w.WriteHeader(http.StatusAccepted)
}

func fetchAndProcess(url string) {
    resp, _ := http.Get(url) // blocks until response or TCP timeout
    // ... do work, write to DB ...
}

What is wrong

Go's standard library binds every incoming request to a `context.Context` that is cancelled when the client disconnects, when the timeout fires, or when the server shuts down. Code that spawns goroutines must propagate that context and respect cancellation — otherwise the goroutine continues to consume CPU, memory, file descriptors, and DB connections long after anyone cares about its result. Goroutine leaks are the Go equivalent of dangling promises in JS, and they accumulate silently until the box runs out of memory.

The attack

Reproducer: 1000 requests per second, all cancelled after 100ms (typical for impatient mobile clients). Without context propagation each cancelled request leaves a worker chewing through `http.Get` (default no timeout) and waiting on a 30-second TCP read. Within minutes, tens of thousands of goroutines, gigabytes of allocations.

textexploit
# pprof goroutine profile after 5 minutes of light load:
# goroutine 142057 [IO wait]:
#   net/http.(*Transport).getConn
#   ... etc
# All blocked on outbound HTTP fetches whose triggers are gone.
# Memory at process: 3.4 GB and climbing.

OOM eventually. Restart cycle. SREs blame the load.

Mesrai's review comment

textreview-comment.txt
mesraipilot · Bot · reviewed 2 min ago

[mesrai] [code-review] [Language] [Goroutine-Leak] [high]

`go fetchAndProcess(url)` is not bound to the request context. When
the client disconnects (cancellation, timeout), the spawned goroutine
keeps running — blocked on `http.Get` with no timeout, holding a
connection slot and memory.

Fix shape:

  func handleImport(w http.ResponseWriter, r *http.Request) {
      url := r.URL.Query().Get("url")
      // Detach from request context but cap with own timeout
      ctx, cancel := context.WithTimeout(context.Background(),
                                          30*time.Second)
      go func() {
          defer cancel()
          fetchAndProcess(ctx, url)
      }()
      w.WriteHeader(http.StatusAccepted)
  }

  func fetchAndProcess(ctx context.Context, url string) error {
      req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
      resp, err := http.DefaultClient.Do(req) // respects ctx
      // ...
  }

Detach (`context.Background()`) because the work should outlive the
HTTP request, but cap with a timeout so it cannot run forever. Pass
ctx down to every blocking call.

The fix

gohandlers/import.go (fixed)
// handlers/import.go — fixed
func handleImport(w http.ResponseWriter, r *http.Request) {
    url := r.URL.Query().Get("url")
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    go func() {
        defer cancel()
        if err := fetchAndProcess(ctx, url); err != nil {
            log.Printf("import failed: %v", err)
        }
    }()
    w.WriteHeader(http.StatusAccepted)
}

func fetchAndProcess(ctx context.Context, url string) error {
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil { return err }
    resp, err := http.DefaultClient.Do(req)
    if err != nil { return err }
    defer resp.Body.Close()
    // ... do work with ctx-aware operations ...
    return nil
}

Detach from the request context (the work must outlive the HTTP response) but bind a fresh timeout so the goroutine cannot run forever. Pass the new context to every blocking call — `http.NewRequestWithContext`, DB calls with `ctx`, channel operations with `select { case <-ctx.Done(): ... }`.

Why human review missed it

Goroutine leaks are invisible at code review because Go's `go` keyword reads as 'just do it later' — the cleanup discipline is implicit. The bug surfaces only under load and only after enough time to OOM. Mesrai catches every `go` statement that does not propagate or detach a context bound to a timeout.

Related rules + further reading

Mesrai rule pack: language/goroutine-context-required — flags `go ...` without context propagation in HTTP handlers.

Go blog: Go Concurrency Patterns: Context.

Common in production Go reports — bottom 25% of OOM root causes in published post-mortems.

Takeaway

Every goroutine spawned from an HTTP handler must carry a context. Either propagate the request context, or detach and time-out explicitly. Mesrai flags the gap.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.