The signal wire
You build a web handler that fetches data from a slow database. The user clicks the button, waits two seconds, and closes the tab. Your server is still talking to the database. It finishes the query, writes the result to a buffer, and tries to send the response. The connection is gone. You just wasted CPU, memory, and database connections for a user who isn't there. This happens constantly in concurrent systems. You need a way to tell that background work to stop the moment the request is no longer needed.
context.Context is the standard way to pass cancellation signals, deadlines, and request-scoped values across API boundaries. Think of it as a shared signal wire running through your function calls. When the top-level caller decides the work is done or failed, it pulls the plug. Every function holding a reference to that context feels the cut immediately. It's not a magic wand. It's a convention wrapped in an interface that lets you coordinate shutdown without threading explicit boolean flags through every layer of your stack.
Minimal example
Here's the simplest pattern: create a context with a deadline, pass it down, and check if it's done.
The worker function listens on ctx.Done() to detect cancellation.
// doWork blocks until the work finishes or the context expires.
func doWork(ctx context.Context) error {
select {
case <-ctx.Done():
// Context was cancelled or deadline exceeded.
return ctx.Err()
case <-time.After(2 * time.Second):
// Work completed successfully.
return nil
}
}
The caller creates the context, sets the deadline, and passes it down.
func main() {
// Create a context with a 500ms timeout.
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
// Release context resources when main returns.
defer cancel()
err := doWork(ctx)
if err != nil {
fmt.Println("stopped:", err)
}
}
How the signal flows
The magic lives in ctx.Done(). This method returns a read-only channel. When you call the cancel function, it closes that channel. Closing a channel in Go broadcasts a signal to every goroutine reading from it. The select statement unblocks immediately. ctx.Err() tells you why: context.Canceled or context.DeadlineExceeded.
The context itself is immutable. You never modify a context. You always derive a new one from an existing one using WithCancel, WithTimeout, or WithValue. This creates a tree of contexts. Cancelling a parent cancels all children automatically. The tree structure ensures that if a request is aborted, every goroutine spawned for that request receives the signal, even if they are deep in the call stack.
You can also inspect the deadline before starting work. ctx.Deadline() returns the time when the context expires. Some libraries use this to set timeouts on underlying connections. For example, a database driver might call ctx.Deadline() to configure the socket timeout. If no deadline is set, the boolean return value is false. This allows optimization. You can avoid starting expensive work if the deadline has already passed.
// Check if the deadline has already expired before starting.
if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) <= 0 {
return ctx.Err()
}
Context is plumbing. Run it through every long-lived call site.
Values and keys
Sometimes you need to pass metadata that applies to the whole request, like a trace ID or a user session. context.WithValue lets you attach key-value pairs to the context. The values propagate down the tree just like the cancellation signal.
Using values requires care. If you use basic types like strings for keys, you risk collisions. If two packages both use "userID" as a key, they overwrite each other. The solution is to define a private key type. Create an unexported type and use that as the key. This guarantees uniqueness within your package.
// keyType is unexported to prevent key collisions across packages.
type keyType string
const userIDKey keyType = "userID"
// Store the value using the unexported key.
ctx := context.WithValue(parent, userIDKey, 123)
// Retrieve the value with a type assertion.
id := ctx.Value(userIDKey).(int)
Don't hide business data in context. Context is for metadata that crosses API boundaries. If a value is needed by a specific function, pass it as an explicit argument. Explicit arguments are visible in the signature and easier to refactor.
Realistic flow
In a real server, the request object carries the context. r.Context() returns the context associated with the incoming request. The server framework creates this context and cancels it when the client disconnects. This links the client lifecycle to your server goroutines automatically.
Here's how context flows through a realistic request: handler extracts the context, spawns a goroutine, and the goroutine checks the context before doing work.
// handleRequest demonstrates context flow in an HTTP handler.
func handleRequest(w http.ResponseWriter, r *http.Request) {
// Extract the context from the request.
ctx := r.Context()
// Spawn background work with the request context.
go func() {
// Check context immediately to avoid wasted work.
if err := ctx.Err(); err != nil {
return
}
// Simulate work that respects cancellation.
select {
case <-ctx.Done():
return
case <-time.After(1 * time.Second):
fmt.Println("work done")
}
}()
// Write response immediately.
w.WriteHeader(http.StatusOK)
}
Notice the function signature in helper functions. context.Context is always the first parameter. The community convention names it ctx. This makes it obvious at a glance that the function supports cancellation. If you see a function taking a context, you know it might block and you can stop it. If you write a function that blocks, it should take a context.
Pitfalls and errors
The compiler catches the most obvious mistake. If you try to pass nil to a function expecting a context.Context, you get cannot use nil as context.Context in argument. Context is an interface, and nil doesn't implement the methods. Always start with context.Background() or context.TODO().
The worst bug is the silent leak. You spawn a goroutine, pass it a context, but the goroutine blocks on a channel that never closes. The context gets cancelled, but the goroutine never checks ctx.Done(). It hangs forever. The context won't kill the goroutine for you. The goroutine must actively listen to the context. If a goroutine can block, it must check the context.
Another common issue is forgetting to call cancel(). The standard pattern is defer cancel() right after the assignment. This ensures the context tree is cleaned up even if the function returns early. Forgetting this leaks the context node and any resources tied to it. The cancel function releases the resources associated with the context, such as timers.
Testing context behavior requires simulating cancellation. In tests, create a context with context.WithCancel, pass it to your function, and call cancel() to verify the function stops. This proves your code respects the signal.
The worst goroutine bug is the one that never logs.
When to use context
Use context.Background() when you need a root context for a new request or test.
Use context.WithTimeout when an operation must finish within a specific time window.
Use context.WithCancel when you need to trigger cancellation manually based on logic.
Use context.WithValue only for request-scoped metadata that crosses API boundaries, such as trace IDs or user sessions.
Use explicit function parameters for business data like IDs, names, or configuration.
Use context.TODO() as a placeholder when you aren't sure which context to pass yet.
Keep context visible. Don't bury it in a struct. Pass it explicitly.