You write a function that takes a context.Context — so it looks cancellation-aware — but the function body never actually checks ctx.Done() anywhere. The caller sets a strict 100ms timeout, confident the work will be cut off. It isn't. The loop just runs to completion, all 250ms of it, and the timeout you configured turns out to have done nothing at all.
func doWork(ctx context.Context) {
for i := 0; i < 5; i++ {
time.Sleep(50 * time.Millisecond)
fmt.Println("tick", i) // never checks ctx.Done() at all
}
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
doWork(ctx)
fmt.Println("ctx err:", ctx.Err())
// tick 0
// tick 1
// tick 2
// tick 3
// tick 4
// ctx err: context deadline exceeded
// (all 5 ticks run, over ~250ms -- the 100ms timeout fired and was ignored)A context.Context carries a cancellation signal, an optional deadline, and optional request-scoped key-value data through a call chain — including across goroutine boundaries. By strong convention, it’s passed as the first parameter of any function that does I/O or could run long, named ctx, so that cancelling one context can stop an entire tree of in-flight work (a slow database call, an HTTP request, several goroutines) with one signal.
WithCancel, WithTimeout, WithDeadline
Every context tree starts from context.Background() (or context.TODO() as a placeholder). WithCancel returns a child context plus a function that cancels it; WithTimeout/WithDeadline do the same but cancel automatically once time runs out. Cancelling a parent context cancels every context derived from it.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() // always call cancel to release resources, even on success
result, err := doSlowThing(ctx)
if err != nil {
fmt.Println("failed or timed out:", err)
}Checking for cancellation inside a function
A function that respects context checks ctx.Done(), a channel that closes when the context is cancelled or times out, and ctx.Err() to see why. This is usually done inside a select alongside whatever real work the function is doing.
func doSlowThing(ctx context.Context) (string, error) {
select {
case <-time.After(5 * time.Second): // the actual (slow) work
return "done", nil
case <-ctx.Done(): // caller cancelled or timed out first
return "", ctx.Err() // context.DeadlineExceeded or context.Canceled
}
}doSlowThing above already shows the fix for the bug in the callout at the top of this page: the function's loop has to select on ctx.Done() itself, every iteration, not just take a context parameter and ignore it. Doing that to doWork from the bug stops the loop the moment the timeout fires, instead of running it to completion:
func doWork(ctx context.Context) {
for i := 0; i < 5; i++ {
select {
case <-ctx.Done():
fmt.Println("cancelled:", ctx.Err())
return
case <-time.After(50 * time.Millisecond):
fmt.Println("tick", i)
}
}
}
// tick 0
// tick 1
// cancelled: context deadline exceeded
// (stops at ~100ms, exactly when the timeout fires)WithValue: request-scoped data (used sparingly)
context.WithValue attaches a key-value pair to a context, most often used for request-scoped metadata like a trace ID or authenticated user, threaded through middleware layers. The Go team explicitly recommends against using it for passing optional function parameters — it bypasses the type system and should carry only data that’s truly request-scoped, not everyday arguments.