When work needs to stop
You are building a web server. A client hits an endpoint that starts a long database query. The client loses patience and closes their browser tab. Your server is still running that query, holding a database connection, wasting CPU, and eventually returning a result to nobody. You need a way to tell the background work, "Stop, nobody cares anymore." That is what context.WithCancel does. It gives you a signal to shut down work gracefully.
The context bundle
A context is a bundle of values that travels through your call stack. It carries deadlines, cancellation signals, and request-scoped data. context.WithCancel creates a context that can be cancelled from the outside. Think of it like a walkie-talkie channel. You create the channel, hand the receiver to your workers, and keep the transmitter. When you speak "cancel" into the transmitter, every worker listening to that channel hears it and stops. The context is not the work itself. It is the coordination mechanism that tells the work when to end.
Contexts follow a strict convention. The context.Context parameter always goes first in a function signature. Name it ctx. Functions that accept a context must respect cancellation and pass the context down to any sub-calls. This convention makes cancellation predictable across the entire codebase.
Minimal cancellation pattern
Here is the simplest pattern. Create a cancellable context, pass it to a goroutine, and call cancel when you are done.
package main
import (
"context"
"fmt"
"time"
)
// doWork simulates a task that checks for cancellation.
func doWork(ctx context.Context) {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop() // Release timer resources when the function returns.
for {
select {
case <-ctx.Done(): // Unblocks if the context is cancelled.
fmt.Println("Work stopped by cancellation")
return
case <-ticker.C: // Tick from the timer.
fmt.Println("Working...")
}
}
}
func main() {
// Create a context with a cancel function.
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // Ensure the context is cleaned up even if main exits early.
// Start the work in a goroutine.
go doWork(ctx)
// Let it run for a bit, then cancel.
time.Sleep(350 * time.Millisecond)
cancel() // Signal the goroutine to stop.
// Wait for the goroutine to finish.
time.Sleep(100 * time.Millisecond)
}
Goroutines are cheap. Cancellation is mandatory.
How cancellation actually works
When you call context.WithCancel(context.Background()), Go allocates a small struct on the heap. This struct holds a channel called Done. Initially, this channel is open. The function returns the context and a cancel function. The cancel function is your handle. When you call cancel(), it closes the Done channel. Closing a channel is a one-time operation. It sends a signal to every goroutine that is reading from Done. Any select statement waiting on <-ctx.Done() unblocks immediately.
The defer cancel() in main is crucial. It guarantees the channel closes even if you return early due to an error. Without it, the context stays open, and goroutines might wait forever. The ctx.Err() function returns the reason for cancellation. It returns nil if the context is still active. If cancelled, it returns context.Canceled. If a deadline passed, it returns context.DeadlineExceeded. Returning this error to the caller helps distinguish between "user gave up" and "server took too long".
Cooperative cancellation
Cancellation is cooperative. Calling cancel() does not kill the goroutine. It closes a channel. The goroutine must be listening to that channel. If your goroutine is stuck in a tight loop doing math, it will not notice the cancellation until it checks ctx.Done(). If it is blocked on a database query, the database driver must support context and you must pass the context to the query. Otherwise, the goroutine hangs until the work finishes or the driver times out. Always design your workers to check the context at natural breakpoints. Use select with ctx.Done() whenever you are waiting for I/O or a timer.
Realistic HTTP handler
In real code, you rarely call cancel() manually in main. You usually wrap a handler or a service method. The context flows down, and you cancel when the request finishes or fails. Here is how a handler uses context to manage a background worker.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
// fetchResults simulates a slow API call that respects cancellation.
func fetchResults(ctx context.Context, query string) ([]string, error) {
select {
case <-time.After(2 * time.Second):
return []string{"result1", "result2"}, nil
case <-ctx.Done():
// Propagate the cancellation error to the caller.
return nil, ctx.Err()
}
}
The handler sets up the context and spawns the worker. Then it waits for the result or cancellation.
// handleSearch processes a request and cancels work if the client leaves.
func handleSearch(w http.ResponseWriter, r *http.Request) {
// r.Context() carries the request lifecycle.
ctx := r.Context()
// Create a derived context for local control.
ctx, cancel := context.WithCancel(ctx)
defer cancel() // Always defer cancel to free the Done channel.
resultsCh := make(chan []string, 1)
go func() {
results, err := fetchResults(ctx, "gofaq")
if err != nil {
resultsCh <- nil
return
}
resultsCh <- results
}()
select {
case results := <-resultsCh:
if results != nil {
fmt.Fprintf(w, "Found: %v\n", results)
}
case <-ctx.Done():
// Client disconnected. Stop waiting.
fmt.Println("Request cancelled")
}
}
Don't leak goroutines. Defer cancel immediately.
Pitfalls and errors
Common mistakes include forgetting to call cancel(), passing the wrong context, or blocking on a channel that never closes. Forgetting defer cancel() causes a goroutine leak. The context stays open, the Done channel never closes, and workers wait forever. The worst goroutine bug is the one that never logs.
Passing nil as the parent context causes a panic. The runtime stops the program with panic: context.WithCancel: parent is nil. Always derive from context.Background() or an existing context. Forget to import the package and the compiler rejects the file with undefined: context. Forget to use a variable and you get declared and not used.
Using ctx.Value for flow control is a design error. Contexts are for cancellation and deadlines, not for passing optional parameters. If you find yourself stuffing arguments into a context, you probably need to change your function signature. Context values should be reserved for request-scoped metadata like user IDs or trace IDs.
Decision matrix
Use context.WithCancel when you need to stop a group of goroutines from a single point of control. Use context.WithTimeout when the operation must finish within a fixed duration, regardless of external signals. Use context.WithDeadline when you have an absolute time limit, such as syncing with an external scheduler. Use r.Context() in HTTP handlers to automatically cancel work when the client disconnects. Use a channel directly when you only need to signal a single goroutine and don't need the context bundle. Use plain sequential code when there is no concurrency to manage.
Pick the right context function. Don't over-engineer.