How to use context for goroutine cancellation
You are building a service that fetches data from a slow upstream API. A user requests the data, but then gets impatient and closes the browser tab. Your server is still working. It is burning CPU cycles, holding a network connection open, and maybe writing to a database it should not touch. You need a way to tell that background work to stop immediately.
Go solves this with context.Context. It is a value that carries a cancellation signal through your call tree. When the signal fires, every function holding that context can clean up and exit. It is not magic. It is a disciplined pattern for coordinating shutdown.
Context is a broadcast signal
Think of context.Context as a walkie-talkie channel shared by a group of workers. You create the context at the top of your task. You pass it down to every function that might take a long time. When something goes wrong, or the user cancels, or a deadline passes, you trigger the context. Every function watching that context hears the signal and stops.
The context value itself is immutable. You cannot change a context once it is created. Instead, you create derived contexts. You start with a root context, wrap it with context.WithCancel, and pass the new context down. The derived context inherits the parent's signal. If the parent cancels, the child cancels too. This forms a tree of dependencies.
By convention, context.Context is always the first parameter of a function. The variable name is almost always ctx. If you see a signature like func Fetch(ctx context.Context, url string), you know it respects cancellation. If the context is buried as the third argument, that function is likely ignoring the signal. Trust the convention. It makes code readable and tools like gofmt happy.
Minimal cancellation pattern
Here is the simplest pattern: spawn a goroutine, use a select to watch the context, and exit when the signal arrives.
package main
import (
"context"
"fmt"
"time"
)
// Worker runs a loop that respects context cancellation.
func Worker(ctx context.Context) {
// Loop indefinitely until the context signals to stop.
for {
select {
case <-ctx.Done():
// The context is canceled. Exit the function immediately.
fmt.Println("Worker stopped")
return
case <-time.After(1 * time.Second):
// Simulate work that takes time.
// This case fires every second if the context is still active.
fmt.Println("Working...")
}
}
}
func main() {
// WithCancel creates a context that can be stopped by calling cancel().
ctx, cancel := context.WithCancel(context.Background())
// Always call cancel to release resources associated with the context.
defer cancel()
// Start the worker in a background goroutine.
go Worker(ctx)
// Let the worker run for a moment, then stop it.
time.Sleep(2 * time.Second)
cancel()
// Give the goroutine time to notice the signal and exit.
time.Sleep(500 * time.Millisecond)
}
The context.Background() call gives you a root context that never cancels. You rarely pass this directly to long-running work. Instead, you wrap it with context.WithCancel. This returns a new context and a cancel function. When you call cancel, it closes an internal channel. Any select statement watching ctx.Done() sees that channel close and jumps to that case.
The defer cancel() call is important. Even if the context is not canceled by your code, calling cancel releases resources held by the context tree. If you skip this, you might leak memory in long-running programs. The compiler will not warn you. The leak is silent.
Realistic example: HTTP handler
In a real service, the request context handles cancellation for you. When a client disconnects, the HTTP framework cancels the context. Your code just needs to respect it.
Here is a handler that spawns a background task. The task must stop if the client goes away.
// FetchData handles a request and spawns a background task.
func FetchData(ctx context.Context) error {
// Derive a new context from the request context.
// This allows the background task to be canceled if the request ends.
taskCtx, cancel := context.WithCancel(ctx)
// Ensure cancel is called to release context resources.
defer cancel()
resultCh := make(chan error, 1)
go func() {
// Execute the heavy work in a separate goroutine.
resultCh <- doHeavyLift(taskCtx)
}()
// Wait for the result or the request to be canceled.
select {
case err := <-resultCh:
return err
case <-ctx.Done():
// The request was canceled.
// The background goroutine will also stop via taskCtx.
return ctx.Err()
}
}
// doHeavyLift simulates work that checks the context.
func doHeavyLift(ctx context.Context) error {
// Check context before starting expensive work.
if err := ctx.Err(); err != nil {
return err
}
// ... actual work ...
return nil
}
The FetchData function derives taskCtx from the incoming ctx. This creates a link. If the HTTP server cancels ctx because the client disconnected, taskCtx also becomes canceled. The goroutine inside doHeavyLift will see the signal.
The select in FetchData waits for two things: the result from the goroutine, or the context cancellation. If the context cancels first, the function returns ctx.Err(). This propagates the cancellation up the stack. The caller can check the error and stop processing.
Context is plumbing. Run it through every long-lived call site.
The context tree and values
Contexts form a tree. context.Background() is the root. Every derived context is a child. When a parent cancels, all children cancel. This is why you always derive from the request context in handlers. The server owns the root. You own the branches.
You can also store values in a context using context.WithValue. This is useful for request-scoped data like user IDs or trace IDs. Use it sparingly. Do not use context to pass optional parameters. That is a design smell. If you need to pass configuration, create a struct.
Keys for context values must be unexported types to avoid collisions between packages.
// userKey is an unexported type for the context key.
type userKey struct{}
// SetUser adds the user ID to the context.
func SetUser(ctx context.Context, id string) context.Context {
// Store the value using the unexported key type.
return context.WithValue(ctx, userKey{}, id)
}
// GetUser retrieves the user ID from the context.
func GetUser(ctx context.Context) string {
// Retrieve the value. Return empty string if not found.
if v, ok := ctx.Value(userKey{}).(string); ok {
return v
}
return ""
}
The userKey struct has no fields. It exists only to provide a unique type. If another package defines its own userKey struct, the keys will not collide because the types are different. This is a Go convention. Never use string or int keys for context values. They collide too easily.
Pitfalls and errors
Goroutine leaks are the most common bug with context. A leak happens when a goroutine waits on a channel that never gets closed. The context signal only works if the code actually checks it. If a goroutine is blocked on an unbuffered channel send, and no one is receiving, the goroutine is stuck. Canceling the context does nothing. The goroutine stays in memory forever.
Always have a cancellation path. Use select with ctx.Done() whenever you block. If you must block on a channel, wrap it in a select.
select {
case msg := <-ch:
// Process message.
case <-ctx.Done():
// Context canceled. Exit.
return
}
If you try to send a value into ctx.Done(), the compiler stops you. The Done channel is receive-only. The compiler rejects the code with cannot send on receive-only channel <-chan struct{}. You never write to Done. You only listen.
Checking ctx.Err() is a point-in-time check. It returns nil if the context is still active. If you check it and then block for ten seconds, you might miss the cancellation. Use select when you are about to block. Use ctx.Err() for quick checks before starting work.
The error returned by a canceled context is context.Canceled. You can check it with errors.Is(err, context.Canceled). This is useful for distinguishing between a user cancellation and a real failure.
The worst goroutine bug is the one that never logs.
Decision matrix
Use context.WithCancel when you need to stop a group of goroutines manually.
Use context.WithTimeout when the operation must finish within a specific duration, otherwise it is a failure.
Use context.WithDeadline when you have an absolute time by which the work must complete.
Use context.Background() only at the very top of your call tree, like in main or a top-level handler.
Use select with ctx.Done() whenever your code is about to block on I/O, a channel, or a timer.
Use ctx.Err() for a quick check before starting expensive work, but never rely on it to stop a blocking operation.
Trust the signal. Cancel early, clean up fast.