The request that never dies
You are building a dashboard that fetches data from three different microservices. One service is slow. The user gets bored and clicks "Refresh" before the first request finishes. The server is still working on the old request, burning CPU and holding a connection open. The new request starts, but the old one never dies. This is a leak. In Go, the tool to kill a request mid-flight is context.Context.
Contexts are not just for HTTP. They are the standard way to carry deadlines, cancellation signals, and request-scoped values across API boundaries. Every function that performs a long-running operation should accept a context as its first parameter. This convention allows the caller to control the lifecycle of the work.
Context as a signal carrier
A context is a signal carrier. It travels down a call chain and tells every function involved: "Stop now" or "You have two seconds left." Think of it like a walkie-talkie channel shared by a team. The leader can shout "Abort!" and everyone hears it instantly. In Go, the context is passed as the first argument to functions. It is a convention, not a language feature, but the standard library relies on it heavily.
The context package provides a tree structure. context.Background() is the root. It never cancels on its own. You create child contexts using context.WithTimeout, context.WithCancel, or context.WithValue. When a parent context is canceled, all its children are canceled automatically. This cascade means you do not need to manually wire up cancellation signals across your codebase. The tree handles it.
Convention aside: context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. If you see a function signature like func Fetch(ctx context.Context, url string), you know it can be stopped early. If the context is missing, the function likely runs until completion or failure, with no way to interrupt it.
Context travels down. Errors travel up.
Minimal timeout example
Here is the simplest way to attach a timeout to an HTTP request.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func main() {
// Create a context that cancels automatically after 5 seconds.
// cancel is a function you call to stop early if needed.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// Always call cancel to release resources, even if the timeout fires.
defer cancel()
// Attach the context to the request.
// The client will check this context while waiting for the response.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://httpbin.org/delay/10", nil)
if err != nil {
fmt.Println("failed to create request:", err)
return
}
// Execute the request.
// If the context times out, Do returns an error.
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println("request failed:", err)
return
}
// Close the body to free the connection.
defer resp.Body.Close()
fmt.Println("Status:", resp.Status)
}
Timeouts save your server. Always set them.
How the cancellation flows
What happens in that code? context.Background() creates the root. context.WithTimeout creates a child context and starts a timer. When 5 seconds pass, the timer fires and the context becomes "done." http.NewRequestWithContext stores this context inside the request object. When http.DefaultClient.Do runs, it checks the context. If the context is done, the client stops reading the response and closes the connection.
Under the hood, every context has a Done channel. When the context is canceled, this channel closes. You can listen to it in a goroutine to stop work early. This is how libraries implement cancellation. The HTTP client checks ctx.Done() while waiting for the response. If the channel closes, it aborts the read.
Contexts are values, not pointers. Passing a context by value is cheap. The underlying implementation uses pointers internally, but the type system treats it as a value. This prevents accidental mutation. You cannot change a context after creating it. You can only derive a new context from an existing one. This immutability makes contexts safe to pass around.
Convention aside: defer cancel() is standard practice. Even if the timeout happens, calling cancel cleans up the timer. It is safe to call cancel multiple times, but defer ensures it runs once. Forgetting to call cancel leaks the timer resource. The compiler will not warn you about this. You must remember to clean up.
Realistic handler with control
Real code often needs more than a timeout. You might want to cancel manually based on user input, or chain contexts. Here is a handler that respects a client timeout and allows manual cancellation.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
// fetchWithControl demonstrates manual cancellation alongside a timeout.
func fetchWithControl(w http.ResponseWriter, r *http.Request) {
// Derive a new context from the request's context.
// This inherits the client's deadline and any cancellation signals.
ctx := r.Context()
// Add a server-side timeout of 3 seconds.
// If the client disconnects, ctx is already canceled.
// If 3 seconds pass, this timeout cancels the operation.
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://slow-api.example.com/data", nil)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
// Check if the error is due to context cancellation.
if ctx.Err() != nil {
fmt.Fprintf(w, "request canceled or timed out: %v", ctx.Err())
return
}
http.Error(w, "request error", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
fmt.Fprintf(w, "Success: %s", resp.Status)
}
Respect the client's deadline. Your handler is part of the chain.
Listening for cancellation in your code
If you write a long-running function, you should check the context periodically. Use a select statement to listen on ctx.Done() and your work channel. This allows the function to exit immediately when the context is canceled, rather than waiting for the current batch to finish.
// processStream reads data and respects context cancellation.
func processStream(ctx context.Context, data <-chan string) error {
for {
select {
case <-ctx.Done():
// Context was canceled or timed out.
// Return immediately to stop processing.
return ctx.Err()
case item, ok := <-data:
if !ok {
// Channel closed, stream ended.
return nil
}
// Process the item.
fmt.Println("processing:", item)
}
}
}
The worst goroutine bug is the one that never logs. Always check ctx.Done() in loops.
Pitfalls and compiler feedback
Common mistakes trip up developers new to Go.
Using context.Background() inside an HTTP handler is a mistake. You lose the client's deadline and any cancellation signals from middleware. Always use r.Context() as the parent in handlers. This ensures your code respects the client's connection state.
Storing a context in a struct is a mistake. Contexts are for request lifecycles, not long-lived objects. If you store a context in a struct, you risk holding onto a canceled context or leaking resources. Pass the context as a parameter instead.
If you pass the wrong type to http.NewRequestWithContext, the compiler rejects it with cannot use ctx (type context.Context) as string value in argument. If you forget to import context, you get undefined: context. The compiler is strict about types. Use the correct types and the code will compile.
When a request times out, http.Client.Do returns an error. The error message usually contains context deadline exceeded. You can check ctx.Err() to confirm. If ctx.Err() is not nil, the context was the cause. This distinction matters when you want to retry. A timeout might be transient. A malformed URL is not.
Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Do not hide errors. Check them immediately.
Don't store context in structs. It belongs in the call stack.
Decision matrix
Use context.WithTimeout when you have a hard limit on how long an operation should take, such as waiting for a database query or an external API.
Use context.WithCancel when you need to stop an operation based on external signals, like a user clicking a cancel button or a test framework shutting down.
Use r.Context() as the parent when writing an HTTP handler, so your code respects the client's connection state and any deadlines set by middleware.
Use context.Background() only at the top level of your program, such as in main or when starting a background worker that has no request to inherit from.
Use context.WithValue sparingly to pass request-scoped data like trace IDs or user sessions, never for optional parameters or performance-critical paths.
Context is a signal. Treat it as the source of truth for lifecycle.