I rolled a new version of our internal payment gateway into production on a rainy Tuesday. Within minutes the ops dashboard started screaming “goroutine count: 120 000+”. Nothing in the logs, no panic, just a slow‑creeping memory bloat that eventually OOM‑killed the pod. The root cause? A handful of request‑handlers that never saw a cancellation signal, left hanging when downstream services timed out. That night taught me two things: 1️⃣ You can’t afford to treat `context.Context` as optional, and 2️⃣ the default `http.Server` timeouts are a safety net most teams forget to enable.
- Never ignore `context` cancellation in handlers or client calls.
- Set **all** `http.Server` timeout fields (Read, Write, Idle, Shutdown).
- Propagate deadlines through middleware and downstream HTTP clients.
- Use `net/http/pprof` + `go.uber.org/goleak` to spot leaks early.
- Graceful shutdown must abort long‑running work, not just stop accepting new connections.
Before you start: Go 1.22 or newer, familiarity with `context.Context`, a running Docker or Kubernetes dev environment, and the `go tool pprof` binary on your PATH.
How to Prevent Goroutine Leaks in Go HTTP Servers
Prevent goroutine leaks in Go HTTP servers by implementing proper context cancellation, configuring all four `http.Server` timeouts (Read, Write, Idle, Shutdown), and propagating context in middleware and client calls. Use `pprof` to detect leaks and implement graceful shutdown to abort long-lived requests during termination.
—
What is a Goroutine Leak?
Symptoms and Impact on Production
A leaking goroutine is a lightweight thread that never returns to the scheduler because it’s blocked on a channel, a network read, or a context that never expires. In a low‑traffic service you might not notice a few stray goroutines, but under load they become a resource leak. Typical symptoms:
| Symptom | Typical Impact |
|---|---|
| `runtime.NumGoroutine()` climbs steadily | Memory consumption rises, GC pressure spikes |
| Latency jitter grows | Requests queue behind stuck workers |
| Pod OOM restarts | Service downtime, lost SLAs |
| No panics, just “slow” | Hard to reproduce locally |
The real pain shows up when a misbehaving endpoint ties up the thread pool and the whole service becomes unresponsive. In 2024, Datadog reported that “requests without deadline” was among the top‑5 signals of runaway latency in Go microservices, confirming that missing cancellation is a production‑grade problem.
How HTTP Servers Are a Common Leak Source
The Go `net/http` package spins up a new goroutine for **every** request that reaches `ServeHTTP`. If your handler launches additional goroutines (e.g., background workers, DB fetches, or streaming responses) and never ties them to the request’s `Context`, those workers keep running even after the client disconnects or the server shuts down. The classic pattern looks like this:
// go1.22
func handler(w http.ResponseWriter, r *http.Request) {
// BAD: fire‑and‑forget goroutine without context
go doWork()
fmt.Fprintln(w, "ok")
}
When `doWork` blocks on a slow downstream call, it never returns, and the goroutine count erupts. The same thing happens when you `Hijack` a connection for websockets or Server‑Sent Events and forget to close the underlying `net.Conn` on client abort.
—
Core Prevention Strategies
Using Request Context Cancellation
The request’s context is cancelled automatically when:
- The client closes the TCP connection.
- The server’s `ReadTimeout` or `WriteTimeout` expires.
- You call `Server.Shutdown`.
Always pull the context out of `*http.Request` and pass it downstream:
// go1.22
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Propagate ctx to DB, HTTP client, etc.
if err := process(ctx); err != nil {
http.Error(w, err.Error(), http.StatusGatewayTimeout)
return
}
fmt.Fprintln(w, "done")
}
When calling external services, use `http.NewRequestWithContext`:
// go1.22
func fetchData(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
// Enforce a client‑side deadline; avoid infinite waits
client := &http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
**My take:** Treat the request context as a *first‑class citizen* – if you can’t pass it through, you’re probably missing a cancellation path.
Implementing Proper Server Timeout Configuration
Go’s `http.Server` offers four timeout knobs. Using only one or two is a recipe for leaks.
| Field | What it guards | Typical value (2026) |
|---|---|---|
| `ReadTimeout` | Time to read the request headers/body | 5 s |
| `WriteTimeout` | Time to write the response headers/body | 10 s |
| `IdleTimeout` | Keep‑alive idle connection limit | 30 s |
| `ReadHeaderTimeout` | Time to read just the headers (optional) | 2 s |
| `ShutdownTimeout` (via `Server.Shutdown`) | Grace period before forced close | 15 s |
Setting them:
// go1.22
srv := &http.Server{
Addr: ":8080",
Handler: router,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
ReadHeaderTimeout: 2 * time.Second,
}
When you call `srv.Shutdown(ctx)`, the server stops accepting new connections and waits for all in‑flight requests to finish **or** for the supplied context to expire. That context should be at least as long as the longest handler you expect.
Managing ResponseWriter Hijacking and Streaming
Hijacking is required for websockets, HTTP/2 server push, or raw TCP streams. The pattern must respect the request context:
// go1.22
func wsHandler(w http.ResponseWriter, r *http.Request) {
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
http.Error(w, "hijack failed", http.StatusInternalServerError)
return
}
defer conn.Close()
// Listen for ctx cancellation to close the socket
go func() {
<-r.Context().Done()
conn.Close()
}()
// Simple echo loop
for {
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
return
}
if _, err := conn.Write(buf[:n]); err != nil {
return
}
}
}
If you omit the goroutine that watches `r.Context().Done()`, the hijacked connection will stay alive forever after the client disconnects, leaking that goroutine and the underlying socket.
—
Modern Go (2024‑2026) Best Practices and Code Examples
Using `http.Server` Timeout Fields Correctly
Since Go 1.22 the default values for timeouts are zero, meaning “no limit”. The community consensus shifted in 2025 to make zero a *configuration error* flagged by static analysis tools like `staticcheck`. Here’s a minimal production‑ready server skeleton:
// go1.22
package main
import (
"context"
"log"
"net/http"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/work", workHandler)
srv := &http.Server{
Addr: ":8080",
Handler: timeoutMiddleware(mux),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
ReadHeaderTimeout: 2 * time.Second,
}
// Run server in its own goroutine
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
// Graceful shutdown on SIGINT/SIGTERM
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("shutdown: %v", err)
}
log.Println("server exited")
}
The `timeoutMiddleware` below injects a per‑request deadline derived from the server’s `WriteTimeout`:
// go1.22
func timeoutMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Derive deadline from server’s WriteTimeout (if any)
deadline := time.Now().Add(10 * time.Second)
ctx, cancel := context.WithDeadline(r.Context(), deadline)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Right vs Wrong: Production‑ready Graceful Shutdown
**Wrong** – just closing the listener:
// go1.22 (bad)
ln, _ := net.Listen("tcp", ":8080")
go http.Serve(ln, handler)
ln.Close() // drops connections abruptly, leaks in‑flight goroutines
**Right** – use `Server.Shutdown` with a bounded context and let handlers observe cancellation:
// go1.22 (good)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("forced shutdown: %v", err)
}
The forced shutdown path (`ctx` expires) will abort any handler still holding the request context, ensuring the associated goroutine can unwind.
Error Handling for Long‑lived HTTP/2 Connections
HTTP/2 multiplexes many streams over a single TCP connection. A single stalled stream can keep the connection open indefinitely, which indirectly increases the goroutine count because each stream runs in its own goroutine. The fix is to enforce per‑stream deadlines:
// go1.22
func streamHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
// The ctx will cancel the HTTP/2 stream if it exceeds 8 s
data, err := fetchLargePayload(ctx)
if err != nil {
http.Error(w, "timeout", http.StatusGatewayTimeout)
return
}
w.Write(data)
}
Because HTTP/2 reuses connections, the server’s global `IdleTimeout` also helps close idle connections, but per‑stream timeouts are the real safeguard against a single slow client hogging resources.
—
Architectural Patterns to Avoid Leaks
Middleware for Context Propagation
A central place to attach timeouts and logging reduces the chance of forgetting a cancellation. Example:
// go1.22
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Generate request ID, add to context for downstream logging
id := uuid.New().String()
ctx := context.WithValue(r.Context(), "reqID", id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Combine it with a `deadlineMiddleware` to guarantee every request gets a deadline.
Worker Pools vs. Goroutine‑per‑Request
The naïve approach — spawn a goroutine for every request — works for low‑traffic services but scales poorly when a downstream service stalls. A bounded worker pool limits concurrency, making the system back‑pressure aware.
// go1.22
type job struct {
ctx context.Context
w http.ResponseWriter
r *http.Request
}
func startPool(size int, handler func(job)) chan<- job {
jobs := make(chan job, size*2) // buffer for burst
for i := 0; i < size; i++ {
go func() {
for j := range jobs {
handler(j)
}
}()
}
return jobs
}
// usage in main()
pool := startPool(100, func(j job) {
// Reuse the same handler logic but now bounded
if err := process(j.ctx); err != nil {
http.Error(j.w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Fprintln(j.w, "ok")
})
func pooledHandler(w http.ResponseWriter, r *http.Request) {
select {
case pool <- job{ctx: r.Context(), w: w, r: r}:
// Accepted
default:
http.Error(w, "service overloaded", http.StatusServiceUnavailable)
}
}
The pool model prevents a downstream spike from spawning millions of goroutines; instead the request is rejected early, preserving memory.
Circuit Breaking for Downstream Services
When a downstream system becomes unresponsive, you don’t want to keep launching attempts that block forever. Wire a circuit breaker that respects the request context:
// go1.22
type breaker struct {
mu sync.Mutex
failures int
openSince time.Time
threshold int
timeout time.Duration
}
func (b *breaker) Execute(ctx context.Context, fn func(context.Context) error) error {
b.mu.Lock()
if b.failures >= b.threshold && time.Since(b.openSince) < b.timeout {
b.mu.Unlock()
return fmt.Errorf("circuit open")
}
b.mu.Unlock()
// Run the function with its own deadline
err := fn(ctx)
b.mu.Lock()
defer b.mu.Unlock()
if err != nil {
b.failures++
if b.failures == b.threshold {
b.openSince = time.Now()
}
return err
}
b.failures = 0 // reset on success
return nil
}
You can plug this into any external client call, ensuring that a cascade of timeouts doesn’t translate into a cascade of goroutine leaks.
—
Production Gotchas, Trade‑offs, and Benchmarks
Monitoring Memory with pprof and Runtime Metrics
Enable the standard pprof endpoints behind a firewall:
import _ "net/http/pprof"
Then scrape `http://localhost:8080/debug/pprof/goroutine?debug=2` or use `go tool pprof`:
go tool pprof -http=:8081 http://localhost:8080/debug/pprof/goroutine
Look for patterns like “