One goroutine calls cancel(). Every goroutine stops. Without any of them talking to each other.
In the previous post, we used a single goroutine checking for cancellation before each store run. One goroutine, one ctx, simple.
Now imagine multiple goroutines running concurrently, each doing a different piece of work, each derived from the same parent context.
Think of it like a family group chat. When you post "party's off," everyone in the group sees it: dad, siblings, whoever dad looped in without telling you, even the siblings who are always online but never respond. You don't need to know who's in there. If they're in the group, they get the message.
That's the Go context tree. One cancel() call, everyone stops, however deep the tree goes.
Before we go further: what does cancel() actually do?
When you call context.WithCancel, Go gives you two things: a context and a cancel function.
ctx, cancel := context.WithCancel(parentCtx)
ctx is what you pass around. It carries a ctx.Done() method that returns a channel goroutines can listen on. cancel is the function that closes that channel. You call it when you want everything to stop.
Reading from ctx.Done() blocks until cancel() is called. At that point the channel closes and all readers unblock simultaneously. Not a broadcast message, not a loop over goroutines. Just a channel closing.
Always pair context.WithCancel with a defer cancel() on the very next line. Even if nothing goes wrong, forgetting it means the context and its internal resources are never released until the parent is cancelled — a silent resource leak.
If you need to communicate why cancellation happened, Go 1.20+ offers context.WithCancelCause — it lets you attach an error to the cancellation, which consumers can retrieve with context.Cause(ctx). For most cases, WithCancel is enough, but it's worth knowing the option exists.
With cancellation covered, here's how goroutines actually listen for it. Goroutines opt in by listening on ctx.Done() in a select:
select {
case <-ctx.Done():
// channel closed — clean up and stop
return ctx.Err() // context.Canceled
default:
// channel still open — keep going
}
New to Go channels and select? A Tour of Go covers them here.
Child contexts work the same way. When you call context.WithCancel(parentCtx), the child's Done() channel is linked to the parent's. Cancel the parent, and the child's channel closes too, automatically, without any extra wiring.
context.Background()andcontext.TODO()create brand new root contexts. They can't be cancelled andctx.Done()returnsnilon both. They're meant to be the starting point of a context tree, not a node in one. If you need cancellation, always derive from an existing context using context.WithCancel or context.WithTimeout.
This pattern shows up in Go's standard library. When an HTTP client disconnects, net/http cancels r.Context() automatically. Any goroutine you spawned with that context stops without you doing anything.
A word of caution: passing cancel into goroutines is powerful but should be done deliberately. In production code, it's good practice to keep cancel scoped as tightly as possible — ideally only the goroutine or function that owns the work should be able to stop it. If many goroutines all hold cancel, it becomes harder to reason about what triggered a cancellation and when.
That's the mechanism the context tree runs on. Let's see it with the party supplies story — four functions, one shared context.
// main — hosts the party, owns the root context
func main()
// watchRSVPs — checks if enough people are coming, calls cancel() if not
func watchRSVPs(ctx context.Context, cancel context.CancelFunc, guests []string)
// coordinatePickup — sends goroutines out to each store
func coordinatePickup(parentCtx context.Context)
// pickup — heads to one store, checks phone before each trip
func pickup(ctx context.Context, item, store string) error
Let's walk through each one.
main() — the host. Creates the root context (the family group), spawns watchRSVPs to watch RSVPs concurrently, then blocks on coordinatePickup. When coordinatePickup returns, defer cancel() fires automatically.
func main() {
guestList := []string{"Alice", "Bob", "Carol", "Dave", "Eve"}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// check RSVPs concurrently — calls cancel() if not enough yes
go watchRSVPs(ctx, cancel, guestList)
// send goroutines to each store — blocks until all finish
coordinatePickup(ctx)
}
watchRSVPs — the RSVP watcher. It holds cancel() and checks whether enough people are coming. It also receives ctx and listens on ctx.Done() inside the loop — because even the goroutine that owns cancel() should be stoppable. If coordinatePickup finishes before all RSVPs come in, the context closes and watchRSVPs returns cleanly instead of blocking forever.
func watchRSVPs(ctx context.Context, cancel context.CancelFunc, guests []string) {
yesCount := 0
for _, guest := range guests {
// check phone before calling the next guest
select {
case <-ctx.Done():
return // supplies already handled — no need to keep checking
default:
}
if getResponse(guest) == "yes" {
yesCount++
}
}
if yesCount < (len(guests)+1)/2 {
cancel() // party's off — ctx.Done() closes for all goroutines
}
}
In the previous post, the condition check happened in main before spawning the goroutine. Here both run concurrently — the pickup is already in motion while RSVPs are still coming in. Any goroutine holding cancel() can pull the plug, at any point, from anywhere in the tree.
coordinatePickup — the delegator. Receives the parent context (the family group) and adds a child context for each item. Each goroutine joins the same family group. When the group gets the cancellation, they all see it. wg.Wait() blocks until all pickups finish.
func coordinatePickup(parentCtx context.Context) {
supplies := map[string]string{
"cake": "https://api.bakery.com",
"plates": "https://api.costco.com",
"balloons": "https://api.partycity.com",
}
var wg sync.WaitGroup
for item, store := range supplies {
wg.Add(1)
childCtx, childCancel := context.WithCancel(parentCtx)
go func(ctx context.Context, cancel context.CancelFunc, item, store string) {
defer wg.Done()
defer cancel() // clean up this goroutine's context when done
pickup(ctx, item, store)
}(childCtx, childCancel, item, store)
}
wg.Wait()
}
New to sync.WaitGroup? Check out the official docs here.
You might wonder: if cancelling the parent already stops all goroutines, why create a child context per goroutine at all? The parent handles fan-out cancellation — one cancel, everyone stops. But the child context lets you cancel one goroutine individually without affecting its siblings. If the cake pickup fails and you want to abort just that one without cancelling the plates and balloons, you call that goroutine's own childCancel(). The parent context is the group chat. The child context is a private message to one person.
pickup — one sibling, one store. Checks the family group (ctx.Done()) before heading out. If the party's already been cancelled, the channel is closed and it stops immediately.
func pickup(ctx context.Context, item, store string) error {
select {
case <-ctx.Done():
fmt.Printf("cancelled — skipping %s\n", item)
return ctx.Err()
default:
}
return fetchFromStore(ctx, store)
}
Here's what they look like together:
// main — hosts the party, owns the root context
func main() {
guestList := []string{"Alice", "Bob", "Carol", "Dave", "Eve"}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// check RSVPs concurrently — calls cancel() if not enough yes
go watchRSVPs(ctx, cancel, guestList)
// send goroutines to each store — blocks until all finish
coordinatePickup(ctx)
}
// watchRSVPs — checks if enough people are coming, calls cancel() if not
func watchRSVPs(ctx context.Context, cancel context.CancelFunc, guests []string) {
yesCount := 0
for _, guest := range guests {
select {
case <-ctx.Done():
return
default:
}
if getResponse(guest) == "yes" {
yesCount++
}
}
if yesCount < (len(guests)+1)/2 {
cancel() // closes ctx.Done() for all goroutines
}
}
// coordinatePickup — sends goroutines out to each store
func coordinatePickup(parentCtx context.Context) {
supplies := map[string]string{
"cake": "https://api.bakery.com",
"plates": "https://api.costco.com",
"balloons": "https://api.partycity.com",
}
var wg sync.WaitGroup
for item, store := range supplies {
wg.Add(1)
childCtx, childCancel := context.WithCancel(parentCtx) // derived context
go func(ctx context.Context, cancel context.CancelFunc, item, store string) {
defer wg.Done()
defer cancel()
pickup(ctx, item, store)
}(childCtx, childCancel, item, store)
}
wg.Wait()
}
// pickup — one sibling, one store, checks the group before heading out
func pickup(ctx context.Context, item, store string) error {
select {
case <-ctx.Done(): // cancelled — stop before the next store
fmt.Printf("cancelled — skipping %s\n", item)
return ctx.Err()
default:
}
return fetchFromStore(ctx, store)
}
No goroutine was cancelled individually. One cancel() call on the parent, everything stops. That's the context tree.
One thing omitted here for clarity: error handling. coordinatePickup spawns goroutines and discards their return values. In real code, you'd want to capture errors — the idiomatic tool for this is errgroup from golang.org/x/sync. It wraps sync.WaitGroup, collects the first non-nil error, and cancels the shared context automatically. Worth reaching for once this pattern feels familiar.
The mistake that breaks the tree: goroutine leaks
I made this mistake myself. I created new contexts mid-flow instead of deriving from the parent, and the first few runs looked fine. But each server restart... the memory climbed a little higher. By the time I opened Task Manager it was already tanking.
There's a version of coordinatePickup that looks almost identical but breaks everything:
// ❌ Wrong — creating a brand new context instead of deriving from parent
childCtx, childCancel := context.WithCancel(context.Background()) // 🚨
vs.
// ✅ Correct — deriving from the parent context
childCtx, childCancel := context.WithCancel(parentCtx)
One character difference in the argument. Completely different behavior.
Imagine dad decided not to delegate to your siblings. Instead he called your cousins. He created a separate "chore group" with just them. Your cousins aren't in the family group. They don't have the family group context.
You text the family group "party's off." Everyone in the family group stops. But the cousins' chore group never gets the message. It's completely disconnected. Cousins keep going. Buying supplies for a party that isn't happening.
That's a goroutine leak. Work continuing that nobody asked for anymore, consuming resources with no way to stop it from the outside.
When you pass context.Background(), you're creating a brand new root, a separate group with no connection to the parent. Your cancel() call never reaches it. The channel it would close is in a completely different context tree.
The rule: always pass context down. Never create a new one mid-flow.
If you ever see a wild goroutine running and eating up resources on your machine with no clear reason why, look for a context.Background() or context.TODO() buried inside a function that should have been receiving and passing a parent context. That's almost always the culprit.
Same context, different roles
The key insight across both posts: watchRSVPs holds cancel() and monitors a condition, pulling the plug when it's met. coordinatePickup holds the parent ctx and passes it down to child goroutines. The pickup goroutines hold child ctx, derived from the parent, cancelled automatically when the parent is cancelled. None of them need to know about each other. ctx and cancel are the only wire between them.
That separation of concerns is what makes Go's context composable. The worker doesn't need to know who cancelled it or why. It just listens on ctx.Done() and cleans up when the channel closes.
New to Go's context? Read Go's context isn't React's Context first — that's where we build the foundation.
What Go misconceptions have cost you the most time? Drop them in the comments — I'd genuinely love to hear them.
Keep Up With My Go Journey
I'm documenting my transition from frontend to backend in public. If you're on a similar path, or just enjoy watching someone learn and fail publicly, follow along:
Top comments (0)