The cancellation signal
You launch a goroutine to fetch data from a slow database. The user closes their browser tab. The HTTP handler returns, but the goroutine keeps waiting for the query to finish. It holds a database connection open. It consumes memory. The server eventually runs out of resources because thousands of these zombie goroutines accumulate. This is a goroutine leak.
Go prevents this with context.Context. The context carries a cancellation signal that propagates through your call stack. Checking if the context is done is how your code responds to that signal. You cannot poll a boolean. You must listen to a channel.
How the Done channel works
Think of a context like a tripwire on a construction site. As long as the wire is intact, work continues. The moment someone trips the wire, every worker connected to it gets an instant signal to stop. In Go, that wire is a channel.
The Done() method returns a channel of type <-chan struct{}. This is a receive-only channel that carries no data. The type is struct{} because the payload does not matter. Only the fact that the channel closed matters. When the context is cancelled or times out, the runtime closes the channel. Any goroutine blocked on <-ctx.Done() wakes up instantly.
The select statement is the standard way to check this channel. It allows you to wait for the context while doing other work. If you just want to check without blocking, add a default case.
Here is the simplest pattern: use a select statement to wait for the context to close.
package main
import (
"context"
"fmt"
"time"
)
// waitForCancellation blocks until the context is done.
func waitForCancellation(ctx context.Context) {
// select waits for any ready case.
// Reading from ctx.Done() returns immediately if the channel is closed.
select {
case <-ctx.Done():
// The channel closed. The context is cancelled or timed out.
fmt.Println("Context finished:", ctx.Err())
}
}
func main() {
// WithTimeout creates a context that cancels automatically after the duration.
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
// defer ensures cancel runs when main returns, releasing resources.
defer cancel()
// Start the waiter in a goroutine so main can continue.
go waitForCancellation(ctx)
// Let the timeout fire.
time.Sleep(100 * time.Millisecond)
}
Inside the select statement
The select statement blocks until one of its cases can proceed. If multiple cases are ready, Go picks one at random. This randomness is intentional. It prevents starvation and keeps the scheduler fair. You must design your code to handle any order of execution.
When ctx.Done() closes, the case case <-ctx.Done(): becomes ready. The read operation returns the zero value of struct{} immediately. You never capture this value. The syntax <-ctx.Done() discards it implicitly. This is idiomatic. You only care that the channel closed, not what came out of it.
After the channel closes, you can call ctx.Err() to find out why. Err() returns context.Canceled if the context was cancelled manually via the cancel function. It returns context.DeadlineExceeded if the timer fired. Err() is safe to call multiple times. It returns the same error value each time.
A convention in Go is that context.Context always goes as the first parameter. The parameter is conventionally named ctx. Functions that take a context should respect cancellation and deadlines. If you ignore the context, you break the contract. Callers expect that passing a cancelled context will stop the work quickly.
Another convention is to always call the cancel function when you are done with the context. Use defer cancel() right after creating the context. The cancel function releases resources associated with the context, such as timers. If you forget to cancel, you leak resources.
Context is plumbing. Run it through every long-lived call site.
Real-world loop
Real code rarely waits for cancellation in a single select. You usually have a loop doing work, and you need to check the context between iterations. The pattern is to put the ctx.Done() case alongside your data channels in the same select.
Here is a worker function that processes items from a channel but stops immediately if the context is cancelled.
// processItems reads from a channel and stops if the context is done.
func processItems(ctx context.Context, items <-chan string) {
// Loop until the items channel closes or context is cancelled.
for {
// select checks both the data channel and the context.
// If both are ready, Go picks one randomly.
select {
case item, ok := <-items:
// ok is false when the items channel closes.
if !ok {
return
}
// Simulate work.
fmt.Println("Processing:", item)
case <-ctx.Done():
// Context cancelled. Stop processing immediately.
fmt.Println("Aborted:", ctx.Err())
return
}
}
}
This pattern ensures that the goroutine does not process items after cancellation. If the context is done, the select picks that case and returns. The goroutine exits cleanly. This prevents goroutine leaks. The worst goroutine bug is the one that never logs.
Common mistakes and compiler errors
A common mistake is busy-waiting. Calling ctx.Err() in a tight loop without sleeping burns CPU. The compiler will not stop you, but your load average will spike.
// BAD: This loop spins and consumes CPU even when nothing is happening.
for ctx.Err() == nil {
// Do work
}
Always use select to block. select puts the goroutine to sleep until a case is ready. It consumes no CPU while waiting.
Another trap is checking ctx.Err() before the Done() channel closes. Err() returns nil if the context is still active. If you check Err() in a loop without checking the channel, you might miss the cancellation signal. The channel close is the signal. Err() is just metadata about the signal.
If you try to send a value into ctx.Done(), the compiler rejects the code with invalid operation: cannot send to receive-only channel ctx.Done(). The channel is strictly for signaling. You never write to it. The runtime controls the channel.
If you forget the <- arrow in a select case, the compiler complains with cannot use ctx.Done() (value of type <-chan struct{}) as struct{} value in case. The arrow is required to read from the channel.
Don't busy-wait. Use select.
When to use what
Use a select statement with <-ctx.Done() when you need to block until cancellation or timeout occurs.
Use ctx.Err() after the Done() channel closes to distinguish between manual cancellation and deadline exceeded.
Use a select with a default case when you need to check for cancellation without blocking the current goroutine.
Use context.WithTimeout or context.WithCancel to create the context that drives the Done() channel.
Use a loop with select when processing a stream of data that must stop if the context is cancelled.
Trust the random selection. Design for any order.