The zombie goroutine problem
You are building a search service. A client sends a request to find all files matching a pattern. The disk scan takes ten seconds. The client loses patience and closes the tab after two seconds. Your server keeps scanning. It holds open file descriptors. It burns CPU cycles for a ghost. The goroutine handling that request is now a zombie, running forever because nobody told it to stop.
In Go, goroutines are independent. Spawning one does not create a parent-child relationship where the parent can kill the child. If a goroutine starts a loop, it runs until the loop finishes or the program exits. You need a mechanism to signal that the work is no longer needed. That mechanism is context.Context.
Context is a signal, not a data bus
context.Context is the standard way to pass deadlines, cancellation signals, and request-scoped data across API boundaries. Think of it as a shared walkie-talkie channel. The parent goroutine holds the transmitter. The worker goroutine holds the receiver. When the parent decides the work is no longer needed, it flips a switch. The worker hears the signal and shuts down immediately.
Context carries three things: a Done channel that receives a value when cancellation happens, a way to retrieve the error reason via Err(), and a map for small key-value pairs. In practice, you use context almost exclusively for cancellation and deadlines. Using context for passing configuration or optional parameters is an anti-pattern that clutters the API and hides dependencies.
The minimal cancellation pattern
Here is the simplest pattern: spawn workers, pass the context, wait for cancellation. The workers check the context in a loop and exit as soon as the signal arrives.
package main
import (
"context"
"fmt"
"time"
)
// Worker runs until the context signals cancellation.
func worker(ctx context.Context, id int) {
for {
select {
// Exit immediately when the context is cancelled.
case <-ctx.Done():
fmt.Printf("Worker %d stopped: %v\n", id, ctx.Err())
return
default:
// Simulate work without blocking the select indefinitely.
time.Sleep(50 * time.Millisecond)
}
}
}
func main() {
// Create a context that auto-cancels after 200ms.
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
// Defer cancel to release resources associated with the context.
defer cancel()
go worker(ctx, 1)
go worker(ctx, 2)
// Block until the context expires.
<-ctx.Done()
}
# output:
Worker 1 stopped: context deadline exceeded
Worker 2 stopped: context deadline exceeded
The select statement is the engine here. It waits on multiple channels. When ctx.Done() receives a value, the first case executes. The default case prevents the select from blocking if you add more cases later, though in this simple loop it just allows the sleep to happen. The key invariant is that the goroutine never blocks for longer than the work chunk without checking ctx.Done().
How the machinery works
context.Background() creates the root context. It is a singleton that is never cancelled. context.WithTimeout wraps that root. The wrapper holds a timer and a channel. When the timer fires, it sends a value down the Done channel and stores the error context.DeadlineExceeded.
The cancel function returned by WithTimeout stops the timer and releases resources. Calling cancel is idempotent; you can call it multiple times safely. The defer cancel() ensures cleanup happens even if the function returns early. If you forget to call cancel, the timer keeps running until the timeout, and the context object leaks memory until the garbage collector reclaims it.
When ctx.Done() receives a value, ctx.Err() returns the reason. It returns context.Canceled if you called cancel manually, or context.DeadlineExceeded if the timer fired. You can check this error to distinguish between a timeout and a user-initiated cancellation.
The community convention is strict: context.Context is always the first parameter of a function, and it is conventionally named ctx. This makes it easy to spot and allows tools to analyze context propagation. Functions that accept a context must respect cancellation. If a function takes a context and ignores it, the contract is broken.
Real-world usage in HTTP handlers
Real code lives in HTTP handlers. The net/http package integrates with context automatically. Every http.Request has a Context() method that returns a context derived from the client connection. If the client closes the connection, the context is cancelled.
// Handler processes a request using the request context.
func Handler(w http.ResponseWriter, r *http.Request) {
// r.Context() carries the client's cancellation signal.
ctx := r.Context()
// Pass ctx to downstream functions.
result, err := DoWork(ctx)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
fmt.Fprintln(w, result)
}
The handler passes the context to DoWork. If the client disconnects, DoWork sees the cancellation and stops. This prevents the server from doing useless work after the client is gone.
// DoWork performs a task that checks for context cancellation.
func DoWork(ctx context.Context) (string, error) {
// Check if context is already cancelled before starting.
if err := ctx.Err(); err != nil {
return "", err
}
// Simulate a blocking operation that respects cancellation.
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(1 * time.Second):
return "done", nil
}
}
The if err := ctx.Err(); err != nil check at the start is a safety net. It handles the case where the context is already cancelled before you enter the function. The select block handles the blocking operation. In production code, you would replace time.After with a database query or HTTP call that accepts a context. Most Go database drivers and HTTP clients have context-aware methods.
The mantra "accept interfaces, return structs" applies here. Functions accept context.Context, which is an interface. This allows the caller to pass any implementation. Functions return derived contexts, which are concrete structs wrapped in the interface. This keeps the API flexible while hiding the implementation details of the timer or cancellation channel.
Pipelines and context propagation
Contexts flow through pipelines. Each stage of a pipeline should check the context. If one stage cancels, the whole pipeline shuts down.
// ProcessStream doubles numbers from the input channel.
func ProcessStream(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case <-ctx.Done():
return
case out <- n * 2:
}
}
}()
return out
}
The goroutine inside ProcessStream checks ctx.Done() before sending each result. If the context is cancelled, the goroutine returns, the deferred close(out) runs, and downstream stages stop receiving. This ensures that cancellation propagates through the entire pipeline.
Pitfalls and compiler errors
Passing a nil context causes a panic at runtime. The compiler won't catch this because context.Context is an interface. If you call ctx.Done() on a nil value, the program crashes with panic: runtime error: invalid memory address or nil pointer dereference. Always derive contexts from context.Background() or http.Request.Context(). Never pass nil.
The compiler rejects cannot use ctx (context.Context) as string value in argument if you mix up types. This happens if you try to pass a context where a string is expected. The type system protects you from accidental misuse.
Storing a context in a struct is a code smell. Contexts are transient. If you put a context in a struct, you risk holding a reference to a cancelled context or leaking the context if the struct outlives the request. The compiler won't stop you, but your architecture will suffer. Pass contexts as arguments. Don't store them in fields.
Using context.WithValue for anything other than request-scoped metadata is an anti-pattern. WithValue is designed for trace IDs, user sessions, or authentication tokens. Do not use it to pass configuration, optional parameters, or data that should be explicit arguments. Hiding dependencies in context makes code harder to test and reason about.
The if err != nil pattern applies to context errors too. if err := ctx.Err(); err != nil { return err }. This is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You cannot ignore a context error without explicitly discarding it.
Goroutine leaks happen when a goroutine waits on a channel that never gets closed. Context provides the escape hatch. Always have a cancellation path. If a goroutine can block, it must check the context. The worst goroutine bug is the one that never logs.
gofmt is mandatory. Don't argue about indentation; let the tool decide. Most editors run it on save. When you write select blocks, gofmt aligns the cases. Trust the formatting. Argue logic, not formatting.
When to use context and when to use something else
Use context.Background() when you need a root context for a new top-level operation.
Use context.WithTimeout when the operation must finish within a specific duration.
Use context.WithCancel when you need to signal cancellation from a specific point in your code.
Use context.WithValue when you must pass request-scoped data like a trace ID or user session, and avoid it for configuration or optional parameters.
Use r.Context() in HTTP handlers to inherit the client's cancellation signal.
Use a channel for communication between goroutines and context for cancellation. Do not use context to pass data between goroutines.
Use plain sequential code when you don't need concurrency. The simplest thing that works is usually the right thing.
Context is plumbing. Run it through every long-lived call site.