Context: Timeouts, Cancellation and Request-Scoped Data
Module 3: Concurrency | Lesson: m03-l05 | Level: Intermediate After this lesson you will be able to: use context for concurrent control and request propagation, and avoid its common anti-patterns.
Learning Objectives
- Understand the design intent of the
context.Contextinterface and its role in Go's concurrency model. - Master
WithCancel,WithTimeoutandWithValue, and how cancellation signals propagate. - Recognize the common
WithValueanti-patterns and learn to correctly pass request-scoped data in practice.
Core Concepts
2.1 What Context Is and Why It Exists
In Go, context is the mechanism for passing cancellation signals, deadlines and request-scoped values across API boundaries and between goroutines.
What:
context.Context is an interface defined as follows:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Deadline: returns the time at which the context is cancelled.Done: returns a channel that is closed when the context is cancelled.Err: returns the reason for the cancellation.Value: returns the value associated with a key.
Why this design:
Go provides no global "cancel all goroutines" variable. If every goroutine needs to know whether its parent task was cancelled, the traditional approach is to pass a chan struct{} or a bool flag around — but that bloats parameter lists and makes hierarchy hard to manage. context solves this with a tree structure (derived contexts):
1. Cancellation propagation: when a parent context is cancelled, all its child contexts are cancelled automatically.
2. Request-scoped data: in scenarios such as HTTP requests, you can pass a request ID, user info, etc. down the call chain without adding it explicitly to every function signature.
2.2 Derived Contexts and Cancellation Propagation
context is usually derived through factory functions. A derived child inherits the parent's state but can add new constraints (such as a timeout) or store new values.
Cancellation propagation: When a parent context is cancelled, the cancellation signal propagates downward to all child contexts. Note that cancellation is one-way: cancelling a child does not affect the parent.
Common factory functions:
- context.WithCancel(parent): returns a derived child context and a cancel function. Calling the cancel function cancels the child and all its descendants.
- context.WithTimeout(parent, d): returns a derived child context that is cancelled automatically after duration d.
- context.WithDeadline(parent, d): returns a derived child context that is cancelled automatically at a specific time d.
2.3 Using WithValue Correctly and Its Anti-Patterns
context.WithValue(parent, key, val) stores a key/value pair in a context.
Correct usage: - Use it only for request-scoped data, such as an HTTP request ID, auth info or trace ID. - Use a custom type for the key to avoid collisions with third-party libraries.
Anti-patterns: 1. Do not store context in a struct field: a context's lifetime is usually shorter than the struct's; storing it extends the context accidentally and leaks resources. 2. Do not use it to pass optional parameters: context is not a generic parameter-passing mechanism; it is mainly for controlling cancellation and carrying request-level metadata. 3. Do not store sensitive information: a context may be logged or inspected by debugging tools; sensitive data (such as passwords) should not go in.
Example Code
Example 1: Timeout Control and Cancellation Propagation
This example shows how to set a timeout with WithTimeout and watch for cancellation with select.
package main
import (
"context"
"fmt"
"time"
)
func main() {
// create a context with a 2-second timeout
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // make sure resources are released
// simulate a long-running task
go func() {
select {
case <-ctx.Done():
// check the reason for cancellation
if err := ctx.Err(); err != nil {
fmt.Printf("Task cancelled due to: %v\n", err)
}
case <-time.After(1 * time.Second):
fmt.Println("Task completed successfully before timeout")
}
}()
// the main goroutine waits 3 seconds, giving the child goroutine
// a chance to run or time out
time.Sleep(3 * time.Second)
}
Expected output:
Task cancelled due to: context deadline exceeded
Example 2: Passing Request-Scoped Data with WithValue
This example shows how to pass a request ID between goroutines with WithValue, and how to read the value safely.
package main
import (
"context"
"fmt"
)
// RequestIDKey is a custom type used to avoid key collisions
type RequestIDKey struct{}
func main() {
// create a base context
ctx := context.Background()
// attach the request ID with WithValue
ctx = context.WithValue(ctx, RequestIDKey{}, "req-12345")
// simulate the function that handles the request
processRequest(ctx)
}
func processRequest(ctx context.Context) {
// read the request ID from the context
requestID, ok := ctx.Value(RequestIDKey{}).(string)
if !ok {
fmt.Println("Request ID not found")
return
}
fmt.Printf("Processing request with ID: %s\n", requestID)
}
Expected output:
Processing request with ID: req-12345
Common Mistakes & Pitfalls
-
Bad code: reusing and mutating the same context's value inside a loop. Why:
WithValuereturns a new context; the original is unchanged. If you mutate the same variable in a loop without reassigning, all goroutines see the same value, or you introduce a race. Correct approach: create a new context each iteration:ctx = context.WithValue(ctx, key, value). -
Bad code: using
<-ctx.Done()for a non-blocking check. Why:<-ctx.Done()is a blocking operation. If the context is not yet cancelled, the current goroutine parks forever, causing a deadlock or a hang. Correct approach: use aselectwith adefaultcase, or checkctx.Err() != nilfor a non-blocking test. -
Bad code: storing
context.Contextlong-term as a struct field. Why: a context's lifetime is usually tied to a request or task, while a struct may live longer. Storing the context extends it accidentally, prevents timely release of resources, and can leak memory. Correct approach: pass context as a function parameter and do not retain it after use.
Best Practices
- First parameter is context: in API design, take
context.Contextas the first parameter, namedctx. This makes it clear to callers that the function supports cancellation and timeouts. - Check Done early: before long operations, check
ctx.Err() != nilor useselect { case <-ctx.Done(): return ctx.Err(); default: }for a non-blocking check, to avoid wasted computation. - Use custom types for keys: do not use
stringorintasWithValuekeys; define a custom type (liketype RequestIDKey struct{}) to avoid collisions. - Do not store sensitive data: a context may be serialized or logged; never put passwords or secrets in it.
- Create context at the boundary: usually create a root context (like
context.Background()orcontext.TODO()) in an HTTP handler or CLI entry point, and derive child contexts internally.
Exercises
- Concept question: explain the two values returned by
context.WithCancel, and how the cancellation signal propagates. - Coding exercise: write a function
fetchData(ctx context.Context, url string) (string, error)that simulates fetching data from a URL. If the context is cancelled or times out, it should return an error immediately. - Advanced/debugging exercise: what is wrong with the following code? How do you fix it? ```go package main
import ( "context" "fmt" "time" )
func main() { ctx := context.Background() for i := 0; i < 3; i++ { ctx = context.WithValue(ctx, "key", i) go func() { fmt.Println(ctx.Value("key")) }() } time.Sleep(1 * time.Second) } ```
Reference Answers
Reference Answers (click to expand / collapse)
Question 1
context.WithCancel returns a derived child context and a cancel function. Cancelling the child cancels it and all its descendants (the signal propagates downward and does not affect the parent).
Question 2
package main
import (
"context"
"fmt"
"time"
)
func fetchData(ctx context.Context, url string) (string, error) {
// simulate a slow network request
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(1 * time.Second):
return fmt.Sprintf("Data from %s", url), nil
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
data, err := fetchData(ctx, "http://example.com")
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Println(data)
}
Question 3
Problem analysis:
1. The loop reuses the same ctx variable and keeps calling WithValue. Although WithValue returns a new context, the goroutines are launched asynchronously and may read ctx at different times. Worse, ctx is overwritten on every iteration, and the goroutines may run only after the loop has finished, at which point ctx holds the value from the last iteration — so all goroutines print the same, possibly wrong, value.
2. The accurate picture: the loop variable itself is not the issue here (Go 1.22 gives a fresh i each iteration), but the goroutines read the shared, reassigned ctx variable after the loop ends, so they all see the final value.
Fixed code:
package main
import (
"context"
"fmt"
"time"
)
func main() {
for i := 0; i < 3; i++ {
// create a fresh context per iteration instead of reusing
ctx := context.WithValue(context.Background(), "key", i)
_ = ctx
go func(val int) {
fmt.Println(val)
}(i)
}
time.Sleep(1 * time.Second)
}
(Note: the preferred approach is to pass the value as a parameter to the goroutine rather than relying on context propagation, unless the context is genuinely carrying request-scoped data.)
Lesson Summary
context.Contextis Go's core interface for passing cancellation signals, timeouts and request-scoped data.WithCancel,WithTimeoutandWithValueare the common ways to create derived contexts.- Cancellation propagates downward; cancelling a child does not affect the parent.
WithValueshould carry only request-scoped data, with custom-type keys.- Do not store context in struct fields, and do not block while checking cancellation.
Next lesson preview: concurrency patterns and pitfalls — worker pools, errgroup, race detection and goroutine leak diagnosis.