The cascade failure
You build a service that fetches data from an upstream API. The upstream API decides to take ten seconds to respond. Your user is waiting. They click the button again. Now you have two requests. Then three. The upstream API is overwhelmed. Your server runs out of goroutines. The whole thing collapses because one slow call blocked the rest. Context stops this cascade.
Context as a signal carrier
Context is a signal carrier. It tells a function when to stop working. You pass a context down through your call stack. Any function that starts a long-running operation, like an HTTP request or a database query, checks the context. If the context says "stop," the function aborts immediately and returns an error. This prevents goroutine leaks and keeps resources free. Context also carries values, but that's a secondary use. The primary job is cancellation and deadlines.
context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. This convention makes it easy to spot functions that can be interrupted.
Context is plumbing. Run it through every long-lived call site.
Minimal example
Here's the simplest pattern: create a context with a timeout, attach it to the request, and let the HTTP client handle the rest.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func main() {
// Create a context with a 2-second deadline.
// The cancel function releases resources, so defer it immediately.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Attach the context to the request.
// The request will abort if the deadline passes.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://httpbin.org/delay/5", nil)
if err != nil {
panic(err)
}
// Execute the request.
// The client monitors the context and cancels the connection if needed.
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println("Request failed:", err)
return
}
defer resp.Body.Close()
fmt.Println("Status:", resp.Status)
}
Runtime behavior
When you call http.NewRequestWithContext, the request stores a reference to the context. When client.Do starts, it monitors the context. If the server responds before the deadline, the request completes normally. If the deadline passes, the client cancels the underlying connection. The Do call returns an error. The error message usually contains context deadline exceeded. Your code checks the error and handles the failure. The goroutine that was blocked on the network call wakes up and exits. No leak.
The HTTP client checks the context at multiple points. It checks before dialing, before sending headers, and while reading the response. If the context cancels at any point, the client stops work and returns. This granularity ensures you don't wait for a TCP handshake if the deadline has already passed.
Realistic handler
In a web server, every incoming request gets a context automatically. The handler receives it via r.Context(). This context cancels when the client disconnects. You should chain your own deadlines to this context so your work stops if the user leaves.
func handler(w http.ResponseWriter, r *http.Request) {
// r.Context() carries the client's connection lifecycle.
// If the user closes their browser, this context cancels.
ctx := r.Context()
// Add a timeout on top of the request context.
// This protects your server even if the client stays connected forever.
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := fetchUpstream(ctx); err != nil {
http.Error(w, "Service unavailable", http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "Success")
}
Your helper functions must accept the context as the first parameter. This is a Go convention. The context flows down the call stack. Every function that performs I/O checks the context. If the context cancels, the function returns an error immediately.
func fetchUpstream(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://slow-api.example.com/data", nil)
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("calling upstream: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("upstream returned %d", resp.StatusCode)
}
return nil
}
if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Wrapping errors with %w preserves the underlying cause, allowing callers to check for specific errors like context.DeadlineExceeded.
Channel waits
When your code waits on a channel, you must check the context. Use a select statement to race the channel against the context's Done channel.
func waitWithCancel(ctx context.Context, ch <-chan string) (string, error) {
select {
case <-ctx.Done():
// Return immediately if the context cancels.
// ctx.Err() returns context.Canceled or context.DeadlineExceeded.
return "", ctx.Err()
case val := <-ch:
// Return the value if it arrives before cancellation.
return val, nil
}
}
The Done channel closes when the context is canceled. Reading from a closed channel returns immediately with the zero value. The select statement picks the ready case. If the context cancels, the first case fires. If the channel sends a value, the second case fires. This pattern prevents goroutines from blocking forever.
Pitfalls and errors
Common mistakes include forgetting to cancel the context, passing nil, or storing context in structs.
If you forget to call cancel, the context holds a timer or a channel that never gets cleaned up. This leaks memory. Always defer the cancel function immediately after creation. The compiler won't warn you about missing cancels. You have to be disciplined.
If you pass nil to http.NewRequestWithContext, the program panics. The compiler won't catch this if you assign nil to a variable. Check for nil or use a helper function that guarantees a valid context.
Storing context in a struct couples the struct to a specific request lifecycle. Context is transient. It belongs to a unit of work. Pass it as an argument to functions. If you need to share state across requests, use a different mechanism.
context.Value exists but is rarely used for HTTP requests. Don't use it to pass authentication tokens or request IDs. Use headers instead. Values in context should be metadata that applies to the entire request scope, like a trace ID. Overusing values makes code harder to trace and breaks the convention of explicit parameters.
The worst goroutine bug is the one that never logs.
Decision matrix
Use context.WithTimeout when you have a hard limit on how long an operation should take. Use context.WithCancel when you need to abort work manually based on application logic. Use r.Context() when writing an HTTP handler to inherit the client's lifecycle. Use context.Background() only at the entry point of your program or top-level goroutines. Use http.NewRequestWithContext for every outgoing HTTP request that might block. Use plain http.Get only in scripts or tests where cancellation doesn't matter.