The request is dead, but the server doesn't know
You build an API endpoint that fetches data from three different databases. The request comes in, your handler starts the work. Halfway through, the client loses internet or the user refreshes the page. The connection drops. Your server doesn't know. It keeps querying the databases, holding onto connections, burning CPU, and writing logs for a request that no one cares about anymore.
This is the problem context solves. It carries a signal through your code telling every layer, "Stop. The request is dead." The context package provides a way to pass deadlines, cancellation signals, and request-scoped values across API boundaries and between goroutines. It turns a collection of independent functions into a coordinated unit that can abort together.
Context is a request ticket
Think of context.Context as a request ticket in a busy kitchen. The ticket travels from the host stand to the chef, then to the dishwasher. The ticket carries the order details, but it also has a cancellation button and a deadline.
If the customer leaves, the host hits cancel. Every person holding a copy of that ticket sees the light turn red and stops working. If the deadline passes, the ticket expires. Everyone stops. The context object is that ticket. It flows through your functions, allowing any part of the system to signal "abort" or "time's up" to every other part.
The context.Context type is an interface. Functions accept the interface, not a concrete implementation. This allows the standard library and third-party packages to work with your contexts seamlessly. You create contexts using factory functions, and you pass them down the call stack. You never return a context from a function; you only derive new contexts from existing ones.
Context is plumbing. Run it through every long-lived call site.
Minimal example
This example shows how to create a context with a timeout and how a function can respect cancellation.
package main
import (
"context"
"fmt"
"time"
)
// DoWork simulates a long-running task that stops when the context cancels.
// The context is the first parameter, named ctx by convention.
func DoWork(ctx context.Context) {
// NewTimer creates a timer that must be stopped to prevent resource leaks.
// Using time.After here would leak a goroutine if the context cancels early.
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
// select waits for either the timer to fire or the context to cancel.
// This pattern allows the function to respond to external signals.
select {
case <-timer.C:
fmt.Println("Work finished normally")
case <-ctx.Done():
// ctx.Done() returns a channel that closes when the context is cancelled.
// ctx.Err() returns the reason for cancellation.
fmt.Println("Work stopped because:", ctx.Err())
}
}
func main() {
// WithTimeout creates a context that auto-cancels after 2 seconds.
// Background creates the root context for the program.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
// Always call cancel to release resources held by the context.
// Defer ensures cleanup happens even if main returns early.
defer cancel()
DoWork(ctx)
}
What happens at runtime
The program starts by calling context.Background(). This function returns a non-nil, empty context that serves as the root for all other contexts. It can never be cancelled. It represents the "top of the world" for your application.
Next, context.WithTimeout wraps that root context. It spawns a background goroutine to watch the clock. It returns a new context and a cancel function. The new context holds a channel that will close when the timeout hits. The cancel function can also be called manually to cancel the context early.
When DoWork runs, it enters a select statement. This statement blocks until one of its cases is ready. The ctx.Done() case listens on the internal channel. After two seconds, the timeout goroutine closes that channel. The select unblocks, picks the cancellation case, and prints the error. The error is context.DeadlineExceeded.
The defer cancel() in main runs when the function returns. Calling cancel stops the timer goroutine and frees memory. If you skip this step, the timer goroutine keeps running until the timer fires, even though the context is already cancelled.
The context flows down. Cancellation flows up.
Realistic example: HTTP handler
In a web server, the net/http package creates a context for every incoming request. You can access it via r.Context(). This context cancels automatically when the client disconnects. You should pass this context to any function that does work for the request.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
// FetchData simulates a database query that respects context cancellation.
// The context is always the first parameter, named ctx by convention.
func FetchData(ctx context.Context, id string) (string, error) {
// Use NewTimer instead of time.After to avoid leaking a goroutine
// if the context cancels before the timer fires.
timer := time.NewTimer(3 * time.Second)
defer timer.Stop()
select {
case <-timer.C:
return "data-for-" + id, nil
case <-ctx.Done():
// Return the context error to propagate cancellation up the stack.
return "", ctx.Err()
}
}
// HandleRequest is an HTTP handler that uses the request's context.
func HandleRequest(w http.ResponseWriter, r *http.Request) {
// r.Context() provides the context tied to the incoming HTTP request.
// It cancels automatically when the client disconnects.
ctx := r.Context()
// Derive a context with a deadline for this specific operation.
// This prevents a slow downstream service from holding the request open forever.
ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()
result, err := FetchData(ctx, "123")
if err != nil {
// Check if the error is due to context cancellation.
if ctx.Err() != nil {
http.Error(w, "Request timed out or cancelled", http.StatusServiceUnavailable)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Fprintln(w, result)
}
func main() {
http.HandleFunc("/data", HandleRequest)
fmt.Println("Server starting on :8080")
// ListenAndServe blocks until the process exits.
http.ListenAndServe(":8080", nil)
}
The handler derives a new context with a one-second timeout. This timeout is stricter than the request's own deadline. If FetchData takes too long, the derived context cancels. FetchData sees the cancellation, stops waiting, and returns an error. The handler checks ctx.Err() to confirm the error came from the context. This pattern ensures that slow operations don't block the server indefinitely.
Always pass the context. Never ignore the cancel function.
Context and goroutines
Goroutines are lightweight, but they are not free. If you spawn a goroutine and forget to stop it, it leaks. Context is the standard way to coordinate goroutine lifecycles.
When you launch a goroutine, pass it a context. The goroutine should monitor ctx.Done(). If the parent cancels the context, the child goroutine exits. This prevents the goroutine from running forever after the request is done.
// Worker runs a background task and stops when the context cancels.
func Worker(ctx context.Context, id int) {
for {
// Check for cancellation at the start of each loop iteration.
// This ensures the goroutine exits promptly when the context is done.
select {
case <-ctx.Done():
fmt.Printf("Worker %d stopping: %v\n", id, ctx.Err())
return
default:
// Do some work here.
// If the work involves blocking calls, pass ctx to those calls.
time.Sleep(100 * time.Millisecond)
}
}
}
The select with a default case allows the loop to check for cancellation without blocking. If the context is not done, the loop continues. If the context is done, the goroutine returns. This pattern is essential for background workers, polling loops, and any long-running task.
The worst goroutine bug is the one that never logs.
Pitfalls and errors
The nil context. If you pass a nil context to a function that calls ctx.Done(), the program panics. The compiler won't catch this because nil is a valid value for the interface type. Always start with context.Background() or context.TODO(). The compiler rejects the code with undefined: ctx if you reference a context variable that doesn't exist.
Forgetting cancel. If you call WithTimeout or WithCancel and never call the returned cancel function, the context holds resources. The timer goroutine keeps running. The memory isn't freed. The compiler rejects the code with cancel defined but not used if you assign it to a variable, but if you ignore it with _, you leak. Call cancel immediately or defer it.
Context as a bag of values. You can store request-scoped values using context.WithValue. This is useful for trace IDs or auth tokens. It is not a way to pass optional parameters. If you find yourself stuffing configuration into the context, move those values to function arguments instead. Values in context are stored in a linked list of closures. Accessing a value walks the chain. This is efficient for shallow chains but can slow down if you nest values too deeply. Keep the chain short.
Ignoring errors. When a function returns an error, check ctx.Err() to see if the error came from cancellation. If ctx.Err() is not nil, the caller should stop processing. The context package defines two sentinel errors. context.Canceled means someone called the cancel function. context.DeadlineExceeded means the timer fired. Your code can check these to distinguish between manual abort and timeout.
Convention aside. Go code runs through gofmt. The context package follows standard formatting. Don't fight the formatter. Most editors run gofmt on save. The receiver name for methods is usually one or two letters matching the type, but context functions are package-level, so this doesn't apply. The underscore _ discards a value intentionally. result, _ := ... says "I considered the second return value and chose to drop it". Use it sparingly with errors, and never with the cancel function.
Context carries signals, not secrets.
When to use context
Use context.Background() when you are at the top level of your program and need a root context. Use context.TODO() when you need to pass a context but don't have one available yet; it signals that the context is a placeholder. Use context.WithTimeout when an operation must finish within a specific duration, such as a database query or an external API call. Use context.WithCancel when you need to manually signal cancellation from another goroutine, like stopping a background worker when the server shuts down. Use context.WithValue only for request-scoped metadata that must traverse API boundaries, such as trace IDs or authentication tokens. Use a regular function parameter instead of context values for business logic data.