1. Why “just wait on a channel” is often not enough
When we write concurrent code, it almost always has spots where the program can wait “forever”: a channel read waits for a sender, a channel write waits for a receiver (or space in the buffer), and a for range ch loop waits until the channel is closed. In tutorial examples this looks fine, but in real life “forever” is not romance—it is a resource leak, a stuck process, and sadness in your team lead’s eyes.
Imagine our training program for tasks (a mini task manager): we want to process a batch of tasks in parallel, for example find the first “urgent” one or collect stats. The user may change their mind, the process may become unnecessary, or we may decide ourselves: “first result found—done, stop the rest.” For that, we need a shared stop signal that different goroutines can listen to.
context.Context and the cancellation channel Done
context.Context is the standard way in Go to pass an “operation execution context”: cancellation, deadlines, and some request-scoped data. For us right now, cancellation is the most important: the context can say “this operation is no longer relevant, stop working.” Key point: Context is safe for concurrent use by multiple goroutines, and you can hand out one context to many workers and then cancel them all at once with a single action.
Technically, cancellation is expressed via the channel: ctx.Done() <-chan struct{}. This is a read-only channel that delivers a “signal” when the context is canceled (or when the deadline is reached, if it is a timeout context). Usually, we do not read a value from it—we just wait for the “close/fire event” via <-ctx.Done().
To create a cancelable context, we most often use context.WithCancel:
import (
"context"
"fmt"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
_ = ctx
cancel() // canceled
fmt.Println("cancel called") // cancel called
}
Here, cancel() is our “red button.” Press it—and everyone listening on ctx.Done() must exit cleanly.
An important practical rule that will save your nerves more than once later: a context is usually passed as a function argument, not hidden inside a struct “for later.” Otherwise, you mix lifetimes and get weird bugs where cancellation “somehow” affects the wrong operations.
2. The main technique: select + case <-ctx.Done()
When we have a select, we can wait for multiple events. Before, we waited for “data from channel A” or “data from channel B,” and also “data or timeout.” Now a third class of events is added: “data or cancellation.” That is, instead of “block forever,” we get a guaranteed exit if someone above says “stop.”
Here is the basic template:
select {
case v := <-ch:
_ = v
case <-ctx.Done():
// stop waiting and exit
return
}
Here, ctx.Done() is the same kind of “event source” as a regular channel. The only difference is that your goroutine is not the one “writing” into it—context cancellation is.
If you want to remember one phrase from this lecture, let it be this: every potentially blocking spot in a concurrent design must have a Plan B—cancellation via ctx.Done().
4. Canceling a channel read
Most often the problem starts with reading. You write a worker that does v := <-jobs, and if nobody writes to jobs anymore (or writes rarely), the worker can wait forever. In a textbook that looks like “so what,” but in an application it means a goroutine that lives, consumes memory for its stack, and holds resources (and sometimes even blocks program shutdown if you wait for it via WaitGroup).
The correct pattern: read from jobs via select, adding a ctx.Done() branch.
import "context"
func worker(ctx context.Context, jobs <-chan int) {
for {
select {
case <-ctx.Done():
return
case v := <-jobs:
_ = v
}
}
}
This code is still “raw”: it does not handle closing jobs. But it already demonstrates the key idea—even if jobs is empty, the worker has an exit path.
Now let’s add correctness for channel closure: the read must check ok, otherwise after close(jobs) you will receive infinite zero values, and the worker will turn into a perpetual motion machine (and perpetual motion machines are usually made only by scammers and beginner developers).
import "context"
func worker(ctx context.Context, jobs <-chan int) {
for {
select {
case <-ctx.Done():
return
case v, ok := <-jobs:
if !ok {
return
}
_ = v
}
}
}
Now the worker has two honest exits: “context canceled” and “channel closed.”
5. Canceling a channel write
The second common pain point is sending a result. You do results <- r, but the receiver may have already left (for example, it got the first answer and exited), or it just reads slowly, or it made a mistake and forgot to read at all. Then the sender hangs on the send.
That is why sends also often need a select with cancellation. It is important to understand: cancellation is needed not only “on reads,” but also “on writes.” This is a very common reflexive bug: a developer adds ctx.Done() only to waiting for input, but the hang happens on output.
Mini template:
import "context"
func sendResult(ctx context.Context, out chan<- int, v int) bool {
select {
case out <- v:
return true
case <-ctx.Done():
return false
}
}
If the function returned false, it means “we could not send because cancellation already happened.” And that is fine: once canceled, the result is no longer needed by anyone.
Notice a subtle point: we do not use default. default makes the send non-blocking, but it can “silently” drop data. This is useful in certain designs (“first result wins”), but there the drop is part of the contract. This approach is shown in classic Go concurrency pattern examples: a non-blocking send via select { case ch <- v: default: }, so as not to leave stuck goroutines behind.
In today’s topic, however, we are learning not to “silently lose,” but to stop work via a shared signal, so ctx.Done() is usually more honest.
6. Example: “first urgent task” with cancellation
Now let’s assemble a small piece of our training app “Tasky” (a hypothetical console task manager): imagine we have a list of tasks and we want to find the first task with priority "urgent" in parallel. As soon as we find it, we cancel the rest of the workers so they do not keep “sifting air.”
Let’s create a task model:
package main
type Task struct {
ID int
Title string
Priority string // "low", "normal", "urgent"
}
Next is a task generator into a channel. Here we intentionally make the channel buffered so the generator does not depend on read speed at every moment (until it fills the buffer). This is not a mandatory part of the idea, but for the example it reduces the chance of accidentally getting a blockage “not where we wanted.”
func feedTasks(tasks []Task) <-chan Task {
ch := make(chan Task, len(tasks))
for _, t := range tasks {
ch <- t
}
close(ch)
return ch
}
Now a worker that reads tasks and, on a match, sends the found task into found. The most important thing about this worker: it must be able to exit on ctx.Done() and not hang when sending a result if cancellation occurred.
import "context"
func findUrgent(ctx context.Context, in <-chan Task, found chan<- Task) {
for {
select {
case <-ctx.Done():
return
case t, ok := <-in:
if !ok {
return
}
if t.Priority == "urgent" {
select {
case found <- t:
case <-ctx.Done():
}
return
}
}
}
}
Notice: we use a nested select for sending. This is not a whim and not “let’s complicate it”: it is protection against the situation where the worker found the task, but the reader of found has already left or cancellation has already happened.
Now main, which starts the worker and waits for either a found task or completion due to timeout/cancellation. A timeout can be done in different ways, but the most convenient in the context of today’s topic is to create a context with a deadline and then work only via ctx.Done() (so we have one single stop signal). You are already familiar with the idea of timeouts via channels and select.
import (
"context"
"fmt"
"time"
)
func main() {
tasks := []Task{
{ID: 1, Title: "Read emails", Priority: "low"},
{ID: 2, Title: "Fix prod bug", Priority: "urgent"},
{ID: 3, Title: "Write report", Priority: "normal"},
}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
in := feedTasks(tasks)
found := make(chan Task, 1)
go findUrgent(ctx, in, found)
select {
case t := <-found:
fmt.Println("FOUND:", t.Title) // FOUND: Fix prod bug
cancel() // stop the others (if there were any)
case <-ctx.Done():
fmt.Println("NOT FOUND:", ctx.Err()) // NOT FOUND: context deadline exceeded
}
}
There are a few important “grown-up” details here that you should not just skim past.
First, found is buffered with 1. This is a small safety net: if the worker finds the result a bit earlier than main reaches the select, the worker can place the value and exit without hanging. This is the same motivation you see in classic concurrency pattern examples: a buffer is often used as an “anti-hang” for the first send.
Second, cancel() in the success branch may look “unnecessary”: after all, we are exiting main anyway. But if we had multiple workers or a more complex setup, cancellation is the correct way to tell the whole system: “we already got the answer, stop working.” Another useful micro-ritual: defer cancel() is almost always a good idea so you do not hold context resources longer than needed (for example, timers inside WithTimeout).
Third, we print ctx.Err(). This is not magic and not “for aesthetics”: ctx.Err() is a way to understand the reason for completion (canceled or deadline).
7. How to write cancelable loops neatly
When you start actively adding ctx.Done() into select, beginners often swing to the other extreme: “let me check ctx.Done() once at the start of the function, and everything will be fine.” It sounds logical, but it works poorly, because blocking can happen in multiple places. Inside a single loop you can block on reading, on writing, on waiting for time, and on something else.
So it helps to keep a simple pattern in mind:
flowchart TD
A[goroutine does work] --> B{can it block?}
B -->|on read| C[select: in / ctx.Done]
B -->|on write| D[select: out / ctx.Done]
B -->|on wait| E[select: timer / ctx.Done]
C --> A
D --> A
E --> A
The idea is that cancellation should be next to each potential blocking point, not somewhere “in one general spot.”
Another neat habit: if a function accepts ctx, it should not suddenly create context.Background() inside itself and live “by its own rules.” The function should respect the context it was given. That is exactly how context becomes an end-to-end mechanism for controlling the operation lifetime.
ctx.Err() and “why did we stop”
So far we discussed ctx.Done() as a “stop signal,” but in real code you often need to understand the reason for stopping. The context has a method Err() error, which returns nil while everything is fine, and an error when the context is done. Usually it is either context.Canceled or context.DeadlineExceeded.
A mini example to see it in action:
import (
"context"
"fmt"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
cancel()
<-ctx.Done()
fmt.Println(ctx.Err()) // context canceled
}
If you use WithTimeout, then after waiting until the end you will get a different reason (in Go it prints as "context deadline exceeded"). Important: we are not “handling an error” as in business logic—we are just diagnosing an event. Context is not a replacement for errors; it is a mechanism for controlling an operation’s lifetime.
8. Common mistakes when using case <-ctx.Done() in select
Error #1: you added cancellation only to waiting for reads, but forgot about sends.
This is a classic: the worker exits honestly when canceled, but it can still hang on out <- result if the receiver has already left. Then it looks like “context does not work,” although it does—the cancellation check is just in the wrong place. A reliable style is to wrap send in a select with ctx.Done() just as disciplined as receive.
Error #2: you forgot to call cancel() because “everything will end anyway.”
Yes, the process may exit together with main, but cancellation is not only for process termination. It is needed to stop unnecessary work earlier, free resources, and give goroutines a clear shutdown protocol. That is why defer cancel() near context creation is not bureaucracy—it is insurance against leaks and “eternal waits.”
Error #3: inside a concurrent design, using context.Background() instead of the passed ctx.
That is how “unkillable” goroutines are born. From the outside you cancel everything correctly, but inside someone says “I’m in my bunker” and creates Background(), which never gets canceled. If a function accepts ctx, it must live by that ctx, otherwise you break the end-to-end cancellation contract.
Error #4: trying to store context.Context in a struct “for later” and reuse it across calls.
This causes lifetime confusion: you canceled one request, and suddenly other operations stopped working because they “accidentally” used the same context. It is much clearer to pass ctx as an argument into each operation, so the context is per-call, not “per object for its whole lifetime.”
Error #5: thinking ctx.Done() will “stop” other goroutines by itself, without their participation.
Context is a signal, not a power switch. It does not kill a goroutine forcibly: the goroutine must regularly check ctx.Done() in a select or via ctx.Err() and exit cleanly. If it is stuck in a blocking operation without a select, it may not notice cancellation at all.
GO TO FULL VERSION