Preventing Resource Leaks in Go HTTP Handlers with Context Cancellation
Learn how to use Go's context package to implement request-scoped timeouts and cancellation in HTTP handlers to prevent goroutine leaks and optimize resource usage.
05 Jul 2026, 23:13 UTC

The Problem: Orphaned Goroutines and Connection Leaks
In a high-traffic Go HTTP server, a client may disconnect or a network timeout may occur while your server is still processing a heavy database query or an external API call. Without explicit cancellation, the server continues to execute that work to completion, consuming CPU and memory for a result that will never be delivered. This leads to "leaked" goroutines and exhausted connection pools, eventually degrading system performance.
The solution is to use the context package to propagate cancellation signals from the incoming request down to every I/O-bound function in your call stack.
Prerequisites
- Go 1.7 or later (standard library
contextpackage). - Downstream functions (DB drivers, HTTP clients) that accept
context.Contextas their first argument.
Implementing Request-Scoped Timeouts
In net/http, every *http.Request carries a context that is automatically canceled by the server when the client closes the connection. To add a specific time limit to a request, you must derive a child context from the request context using context.WithTimeout.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
// 1. Derive a timeout context from the request context
// This ensures work stops if the client disconnects OR 2 seconds pass
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel() // Always call cancel to release resources immediately
// 2. Pass the context to downstream functions
result, err := performExpensiveWork(ctx)
if err != nil {
if err == context.DeadlineExceeded {
http.Error(w, "Gateway Timeout", http.StatusGatewayTimeout)
return
}
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
fmt.Fprint(w, result)
}
func performExpensiveWork(ctx context.Context) (string, error) {
// Simulate a long-running task
select {
case <-time.After(5 * time.Second):
return "Success", nil
case <-ctx.Done():
// Return the context error (Canceled or DeadlineExceeded)
return "", ctx.Err()
}
}
Critical Implementation Details
The Importance of defer cancel()
When you create a context with WithTimeout or WithCancel, the runtime allocates resources to track the timer or the cancellation signal. If you do not call the cancel function, those resources are not released until the timer naturally expires, even if the work finished in milliseconds. Always use defer cancel() immediately after the context creation.
Cooperative Cancellation
Context cancellation is cooperative. The context package does not forcibly kill a goroutine. Your code must actively check for cancellation. This is done in two ways:
- Polling: Checking
ctx.Err()inside a loop. - Blocking: Using a
selectstatement to listen to the<-ctx.Done()channel.
If you call a library function that does not accept a context, the cancellation signal stops there, and the underlying work will continue until it finishes or hits a hard TCP timeout.
Comparing Context Strategies
| Approach | Behavior | Best Use Case |
|---|---|---|
r.Context() |
Cancels when client disconnects. | General request lifecycle. |
context.WithTimeout |
Cancels after a fixed duration. | External API calls, DB queries. |
context.Background() |
Never cancels. | Main function, top-level background tasks. |
Verification and Diagnostics
To verify that your cancellation logic is working, you can use a test with a slow mock server and a client that cancels the request early.
Verification Steps:
- Start an
httptest.NewServerwith a handler that sleeps for 10 seconds but listens toctx.Done(). - Create an
http.Clientand a context with a 1-second timeout. - Execute the request using
http.NewRequestWithContext. - Assert that the client receives a timeout error and the server logs the
context.DeadlineExceedederror promptly.
Diagnostic Check: Run your tests with the -race flag. If you are accessing shared variables across the handler and a background goroutine without proper synchronization during cancellation, the race detector will identify the conflict.
Rollback and Recovery
Because context usage does not change the state of the application (it only manages the lifecycle of a request), there is no "rollback" in the traditional sense. However, if you encounter context.DeadlineExceeded, follow these recovery patterns:
- Mapping Errors: Map
DeadlineExceededto HTTP 504 (Gateway Timeout) to inform the client that the server timed out waiting for a downstream dependency. - Retries: If you implement a retry mechanism, never reuse the expired context. Create a new context with a fresh timeout for the retry attempt.
- Logging: Log
context.Canceledas anINFOlevel event rather than anERROR. A client closing a browser tab is normal behavior and should not trigger on-call alerts.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.