AlgoMaster Logo

context.WithCancel

High Priority12 min readUpdated June 6, 2026

context.WithCancel is the simplest way to give a goroutine a stop button. You hand it a parent context and it gives you back a child context plus a cancel function. Calling cancel closes the child's Done channel, which is how any goroutine listening on that channel learns it should bail out. This chapter walks through the signature, the discipline around the cancel function, how cancellation propagates through a tree of contexts, and the classic mistakes that turn cancellation into a goroutine leak.

The Signature and What You Get Back

The function lives in the context package and looks like this:

You give it a parent context, and you get two things back. The first is a new child context. The second is a CancelFunc, which is just type CancelFunc func(). Calling that function cancels the child context (and all of its descendants), unblocks any goroutine waiting on <-ctx.Done(), and causes ctx.Err() to return context.Canceled.

Here's the smallest possible example. A goroutine waits for a stop signal, the main function sends it.

The goroutine doesn't poll, it blocks on <-ctx.Done(). The channel returned by Done() is closed when cancel() is called, and a closed channel always unblocks a receive. After cancellation, ctx.Err() returns the sentinel error context.Canceled so the goroutine can tell why it woke up.

Always defer cancel()

Every time you call WithCancel, the runtime sets up a small piece of bookkeeping: a goroutine-safe state object, a link to the parent, and (for descendants) a registration in the parent so the parent can cancel its children when it gets canceled itself. Calling cancel() releases that bookkeeping. If you never call it, the bookkeeping lives until the parent context is canceled, which might be never if the parent is context.Background().

The idiomatic fix is to defer cancel() on the same line as the WithCancel call:

The defer cancel() runs whether searchProducts returns normally, returns early, or panics. The cancel is safe to call even when the search finished on its own, because CancelFunc is idempotent: calling it a second time (or a tenth time) is a no-op.

The standard library checks this for you. If you write _ = cancel or simply ignore the second return value, go vet will complain. Treat that warning as a build error.

How a Goroutine Cooperates

A context can only cancel a goroutine that is willing to be canceled. The goroutine has to check, and the standard way to check is the select statement listening on <-ctx.Done() alongside whatever real work the goroutine is doing.

A product search that pretends to talk to a slow database is a good example. The work is a time.After, the cancellation channel is ctx.Done(), and the select picks whichever fires first.

The user starts a search, then leaves the page 50 ms later. The main goroutine calls cancel, the search goroutine wakes up on <-ctx.Done(), prints why, and returns. The "results" branch never fires because the cancellation happened first.

For loop-style work, the pattern is the same idea wrapped in a for:

The select with a default branch is a non-blocking check at the top of each loop iteration. If the context is canceled, the goroutine returns. If not, the default lets the loop continue. This shape works well when each iteration is short and you want to break out of the loop quickly, not when you need to block on a channel and a cancellation at the same time. For the blocking case, use the multi-case select from the earlier example.

The diagram shows the pattern. A goroutine that respects context is always sitting at some select (or polling ctx.Err()) where two outcomes are possible: the work finishes, or the cancel signal arrives. Whichever happens first wins, and the goroutine returns either way.

Cancellation Propagates Down the Tree

The most useful property of WithCancel shows up when you build a tree of contexts. Each WithCancel (and WithTimeout, and WithDeadline, and WithValue) takes a parent and creates a child. Cancellation flows one direction only: from parent to children. Cancel a node and every descendant is canceled at the same moment. Cancel a child and the parent isn't affected.

Consider a checkout flow that spawns three subtasks: charge the card, reserve inventory, and send a confirmation email. All three share a single parent context for the request. If the user closes the page, the request-level cancel should reach all three subtasks.

One cancel() call at the root closes Done() for the root and every node beneath it. The three subtasks don't need to know about each other. They each pass the context they were given to whatever blocking work they do, and when the root cancels, they all wake up together.

Here's that picture as runnable code:

One cancel() stopped three independent goroutines. None of them knew about the others; they only knew about the context they were given. That's the value of the propagation: cancellation flows along the natural shape of your call graph without you wiring up signals by hand.

Now look at what happens when a child has its own cancel. The child can cancel itself without taking the parent down with it.

Cancellation only flows down. The child being canceled doesn't touch the parent, which makes sense: the parent might have other children doing useful work, and a sibling's failure shouldn't stop them.

What's Wrong With This Code?

A common cancellation bug is a goroutine that ignores the context it was given. The cancel signal fires, the parent moves on, but the goroutine keeps running. Production code that does this leaks goroutines forever.

searchProducts accepts a context.Context, but never reads from it. cancel() does its job (it closes ctx.Done()), but nobody is listening. The goroutine plows through all five results even though the caller wanted it to stop after the first one. In a real server, this same shape leaks a goroutine per request when the client disconnects.

The fix is to wire ctx.Done() into whatever blocking operation the goroutine is doing. For the sleep loop, that means wrapping the sleep in a select:

Now the goroutine returns within microseconds of the cancel. The lesson is general: any goroutine that takes a context.Context parameter has a contract with its caller to honor cancellation. If you accept a context, you must check it.

A Realistic E-Commerce Example

Putting it all together, here's a search-as-you-type style flow. The user types one query, then a moment later changes their mind and types another. The first search should be canceled the moment the second one starts, so the server doesn't burn cycles producing results nobody wants.

The first search runs for one step, then the second query cancels it. The second search runs all five steps because nothing cancels it. Notice the structure: each search has its own context with its own cancel. The launcher decides when to cancel each one, and the search goroutines just honor the contract.

This pattern shows up everywhere in real services. Any time a user action supersedes an earlier one (clicks "back", retypes a query, abandons a checkout step), the server-side work for the earlier action should stop. WithCancel is the tool that lets you express that without inventing a signal channel by hand.

You can also combine WithCancel with subtasks. A checkout might spawn three child goroutines and wrap each in its own cancelable context, all derived from the request-level context.

The client disconnect cancels requestCtx. checkout's ctx is derived from requestCtx, so it gets canceled too, which cancels all three subtasks. Three layers, one signal, everything shuts down cleanly. The defer cancel() inside checkout is still important: if checkout ever returns before the client disconnects, that defer releases the bookkeeping for the local cancelable context.

Quiz

context.WithCancel Quiz

10 quizzes