Skip to content

Go context, fully explained — cancellation, time budgets, and four pitfalls

Contents10 sections

I was already taking ctx as the first argument and passing it along. What I had never checked was whether canceling actually stops the goroutine. After stalling on that question in an interview, I went back to context from the start. Cancellation only sends a signal to stop; the side that receives it is the one that actually stops.

Why it exists: ghost work

One incoming request fans out inside the server. It hits the database, calls an external API, reads files, and each of those may start more goroutines. Now suppose the client hangs up after three seconds.

With no way to tell anyone, the server keeps producing results nobody is waiting for. That’s ghost work. It goes unnoticed most of the time, but once an incident slows responses and clients start timing out, abandoned work piles up, eats every resource, and turns into a cascading failure. Context is the standard way to carry “you can stop now” through the whole call chain.

What it actually is turns out to be simple: an interface with four methods.

context.Context
type Context interface {
Deadline() (deadline time.Time, ok bool) // until when
Done() <-chan struct{} // a channel that signals the end (by closing)
Err() error // why it ended (Canceled / DeadlineExceeded)
Value(key any) any // request-scoped values
}

Using it comes down to four constructors and two ways of reading.

Create When
Background() The root of the tree. main, initialization, tests, and the top of each incoming request
WithCancel When you want to cut it off yourself
WithTimeout / WithDeadline When you want a time limit. The one you’ll use most in practice
WithValue Only request-scoped metadata such as request IDs and traces (more below)
Read What it tells you
select + ctx.Done() Whether it has ended
ctx.Err() Why it ended: Canceled or DeadlineExceeded

The important part is that these build a tree. Each WithXxx takes a parent and returns a derived context, and cancellation flows only from top to bottom.

Background()the root of the treeWithTimeout(3s)the lifetime of one requestDB queryWithTimeout(1s) · its own 1sExternal APItakes the parent ctx as-isCache refreshsplit off into a goroutineCancel the request and all three go too
One request makes one context tree, and cancellation only flows downward

Being a tree brings two rules. First, canceling a child leaves the parent untouched. If the DB query hits its 1-second timeout, the request as a whole keeps going. Second, deadlines only get shorter. If the parent has three seconds and a child asks for ten, the real limit is still three. That you can’t extend it is the safety feature.

Cancellation is a request: the receiver stops

In an interview I was once asked whether canceling a context kills the goroutine, and I stalled. The answer is no.

Go has no way at all to forcibly stop a goroutine from outside. There’s no API to kill a thread and no interrupts. So cancel() only closes a channel to say “you can stop now,” and actually stopping is up to the receiver. The documentation is explicit that a CancelFunc does not wait for the work to stop.

✓A loop that checks Done()

for _, vm := range vms {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
copyDisk(ctx, vm)
}

It exits on the next iteration.

✕A loop that ignores Done()

for _, vm := range vms {
copyDisk(vm)
}

The signal arrives but nobody checks, so it runs to the end. That’s ghost work.

When someone asks why canceling didn’t stop anything, the answer is usually the loop that ignores Done(). That makes where you put the check the real design decision. The rule of thumb: right before anything that takes a while.

Check right before the slow part
func migrate(ctx context.Context, vms []VM) error {
for _, vm := range vms {
select {
case <-ctx.Done():
return ctx.Err() // Canceled or DeadlineExceeded
default: // with default, this never blocks
}
if err := copyDisk(ctx, vm); err != nil { // pass it down as well
return err
}
}
return nil
}

Fortunately, the standard library handles its side. APIs that take a ctx, such as net/http and database/sql, watch Done() internally, stop when it fires, and return an error. The checks you have to add yourself are in your own long loops and CPU-heavy work.

If a server passes ctx everywhere and the load still doesn’t drop after cancellation, there’s probably a stretch of the chain that never looks at Done(). When that happens, I start by looking for the long-running loops.

Why Done() works as a broadcast

This raised a question for me. With a hundred goroutines, how does one cancellation reach all hundred at once? Send a value on a channel and only one receiver gets it.

The answer is that nothing is sent. Context closes the channel. Receiving from a closed channel immediately returns the zero value to every waiter, and they all wake up. A single channel can notify any number of goroutines.

Send a value: one receiverch <- vworker 1got itworker 2waitingworker 3waitingClose the channel: everyone wakesclose(ch)worker 1woke upworker 2woke upworker 3woke up
Send one value and one waiter wakes; close the channel and every waiter wakes

This also explains why Done() returns <-chan struct{}. Because it’s receive-only, the receiving side can’t close it by accident (that’s a compile error), and struct{} takes zero bytes, so the signal itself costs nothing.

That property gives select two natural patterns. Pick whichever fits.

Non-blocking check and blocking wait
// ① Non-blocking check: in the middle of a compute loop, just ask "canceled yet?"
select {
case <-ctx.Done():
return ctx.Err()
default: // with default, this doesn't block
}
// ② Blocking wait: take whichever comes first, the result or the cancellation
select {
case res := <-resultCh:
return res, nil
case <-ctx.Done():
return nil, ctx.Err() // keeps you from blocking forever on a result
}

Time budgets: deadlines only get shorter

This was the last piece of context to click for me. I had thought of WithTimeout as “cut it off after a few seconds,” but it actually assigns a time budget to the whole request and makes the chain share it.

A derived context’s deadline can never be later than its parent’s. If the parent ends in three seconds and the child asks for ten, the limit is still three. The further down you go, the less time is left.

The further down the chain, the less budget is left
  • API gateway3.0s left
  • Order service2.7s left
  • Inventory service2.2s left
  • DB query1.7s left

What upstream spends, downstream loses. That's why you don't need to work out a timeout for every service

The budget even crosses process boundaries. gRPC sends the deadline in the request metadata, and the receiving server’s ctx gets the same deadline. Pass that ctx on to the next call and the entire chain of services shares one budget. Set three seconds at the front and the limit follows all the way to the database at the back.

The parent sets the limit
parent, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
child, cancel2 := context.WithTimeout(parent, 10*time.Second) // pointless: the real limit is 3s
defer cancel2()
Deciding by the time left
// You can check the remaining time yourself before heavy work
if dl, ok := ctx.Deadline(); ok && time.Until(dl) < 500*time.Millisecond {
return ErrNotEnoughTime // if it can't finish anyway, don't start
}
The request ctx in an HTTP server
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // canceled when the client's connection closes
if err := doWork(ctx); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

Pitfall ①: skip cancel and it leaks

I wrote defer cancel() by rote for a long time without knowing exactly what leaks if you don’t. The documentation answers briefly. Calling cancel does three things:

  1. It cancels the child and its children.
  2. It removes the parent’s reference to the child.
  3. It stops any associated timers.

Skip it and none of that happens. In the documentation’s words, the child leaks until the parent is canceled or the timer fires. If you derived it with WithCancel from a long-lived parent, that moment is when the server shuts down. You won’t notice it on one request; you will at thousands per second.

What if the timeout has already passed? When the timer fires, the context cancels itself and drops the parent’s reference too, so cancel has almost nothing left to do. The real problem is work that finishes early. If it’s done in two seconds and you never call cancel, the timer and the reference stay around for the remaining second. Either way, defer cancel() right after creating the context means you never have to think about it.

🧪 Try it

go vet catches a missing cancel

I deliberately wrote code that calls cancel on only one branch and ran go vet.

leak.go
func leak(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
if ctx == nil {
cancel()
return
}
}
Output
$ go vet ./...
./leak.go:9:2: the cancel function is not used on all paths (possible context leak)
./leak.go:14:1: this return statement may be reached without using the cancel var defined on line 9

It checks that cancel is called on every path and even points at the return that skips it. Put go vet in CI and nobody has to remember this one.

Pitfall ②: cancellation takes your cleanup down too

I only learned this one by living through it. A job failed and kicked off a rollback, and the rollback was being passed the same ctx. That ctx was already canceled. The rollback was canceled the moment it started, the snapshot it was supposed to delete stayed in storage, and the bill kept running. The error log only said context canceled, which made it hard to see that the real failure was the cleanup.

In hindsight it’s obvious. A cancellation means “stop all work related to this request,” and cleanup is part of that work. So cleanup has to be explicitly detached from the cancellation signal.

✕Clean up with the same ctx

func rollback(ctx context.Context, id string) {
deleteSnapshot(ctx, id) // context canceled
}

The cleanup dies as soon as it starts, and resources you meant to delete keep costing money.

✓Detach it and set a fresh limit

func rollback(ctx context.Context, id string) {
cctx := context.WithoutCancel(ctx)
cctx, cancel := context.WithTimeout(cctx, 30*time.Second)
defer cancel()
deleteSnapshot(cctx, id)
}

The cleanup always runs, and it can’t hang forever either (Go 1.21+).

WithoutCancel keeps the values and cuts only the cancellation. The nice part is that the request ID and trace data survive, so the cleanup still logs with them. But the deadline goes away too, so you must set a new timeout. Otherwise you get the opposite problem: a cleanup that never ends.

Pitfalls ③④: overusing WithValue and storing ctx in structs

The other two are about using context where it doesn’t belong. The documentation spells out the rules clearly, so when in doubt, follow them.

Rule Why
Don’t store it in a struct A ctx is tied to the lifetime of one call, not of an object. Stored in a field, nobody can tell when it will be canceled
First parameter, named ctx It’s the documented convention, so both readers and linters expect it there
Never pass nil If you don’t know what to pass, pass context.TODO(). It marks a spot to fix later
Value is for request-scoped data only It is not a way to pass optional function parameters
Use an unexported type for keys Basic types like string collide with keys from other packages

The fourth row goes wrong most often in practice. WithValue looks convenient, but once required parameters start hiding in it, the signature no longer tells you what a function needs, and type safety is gone. A missing value still compiles, and the type assertion that reads it panics at run time.

Required values as arguments, keys as unexported types
// Bad: a required parameter hidden in ctx. The signature tells you nothing
func createOrder(ctx context.Context) error {
userID := ctx.Value("userID").(string) // panics at run time if missing
// ...
}
// Good: required values as arguments, only request metadata in ctx
func createOrder(ctx context.Context, userID string) error { /* ... */ }
// Keys must be an unexported type
type traceIDKey struct{} // no other package can construct this type
func WithTraceID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, traceIDKey{}, id)
}
func TraceIDFrom(ctx context.Context) (string, bool) { // ship a type-safe accessor with it
id, ok := ctx.Value(traceIDKey{}).(string)
return id, ok
}
// A method argument, not a struct field
type Server struct{ ctx context.Context } // bad
func (s *Server) Handle(ctx context.Context) error // good

Newer APIs worth knowing

The context package has quietly kept growing. In particular, not knowing why something was canceled got fixed in 1.20, and I’d been staring at meaningless context canceled logs without knowing it.

  • WithCancelCause, Cause (1.20): attach a reason to a cancellation. Debugging gets much easier.
  • WithoutCancel (1.21): detach work that must finish even after cancellation, like cleanup and audit logs.
  • AfterFunc (1.21): run a function when the ctx ends, without starting your own goroutine to watch Done().
  • WithTimeoutCause, WithDeadlineCause (1.21): attach a reason to timeouts too, so you can tell which stage blew the budget.
Recording why it was canceled
ctx, cancel := context.WithCancelCause(parent)
cancel(fmt.Errorf("upstream returned repeated 5xx: %w", err)) // err is the last 502
fmt.Println(ctx.Err(), "|", context.Cause(ctx))
Output
context canceled | upstream returned repeated 5xx: 502

Err() always says context canceled, which was never much help. The information I actually needed lives in Cause().

When any one of several tasks failing should cancel all of them, errgroup wraps the pattern for you.

errgroup: the first error cancels the rest
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error { return fetchUser(gctx) })
g.Go(func() error { return fetchOrders(gctx) })
if err := g.Wait(); err != nil { // the first error cancels gctx automatically
return err
}

To tell errors apart, use the approach from part 3. The cause may be wrapped, so use errors.Is instead of ==.

Handling each cause differently
if errors.Is(err, context.DeadlineExceeded) {
return status(504) // timed out
}
if errors.Is(err, context.Canceled) {
return // the client already left; no need to respond
}

Summary

  • Go can’t kill a goroutine from outside. Long loops get their own Done() checks.
  • Done() closes a channel instead of sending a value, so every waiter wakes at once.
  • The time budget set at the front follows the chain to the end, and with gRPC it crosses process boundaries.
  • Skip cancel and child contexts and timers stay attached to the parent, so write defer cancel() right after creating one. go vet catches the misses.
  • Detach cleanup with WithoutCancel and give it its own timeout.

After five parts

Looking back, the pitfalls in each part had the same shape: an interface value being two slots, a slice being a window onto an array, what defer and closures hold on to and when, embedding’s promotion never routing calls back out, and cancellation being a signal rather than a command. Every time, I fell in by misjudging what the thing actually was. The wrap-up post folds all of this along two axes.

Go doesn’t hide the underlying reality for convenience. That felt unfriendly at first, but now it seems like a design that pulls most of the learning curve up front.

References

Edit on GitHub
esc

Recent posts

↑↓ move↵ openOpen search page ›