Context: Cancellation, Timeout and Value Propagation
Context: Cancellation, Timeout and Value Propagation
In server programs, each incoming request is typically handled in its own goroutine, which may spawn additional goroutines to access databases, make RPC calls, or request downstream services. When a request is cancelled or times out, all goroutines working on that request should exit promptly, releasing resources. This is the core problem the context package solves.
The context package was designed by Sameer Ajmani, first introduced publicly in the 2014 blog post "Go Concurrency Patterns: Context" (The Go Blog), and officially added to the standard library in Go 1.7. Its design goal is singular: propagate deadlines, cancellation signals, and request-scoped values across API boundaries and goroutines.
Level 1: What You Need to Know
What Is Context
context.Context is an interface defining four methods:
type Context interface {
// Returns deadline. If no deadline is set, ok = false
Deadline() (deadline time.Time, ok bool)
// Returns a channel that's closed when context is cancelled
Done() <-chan struct{}
// After Done channel is closed, returns cancellation reason
Err() error
// Returns value associated with key, or nil if none
Value(key interface{}) interface{}
}
You don't need to implement this interface yourself—the standard library provides all implementations you need.
Two Root Contexts
Every context tree's root node has only two choices:
// For main functions, initialization, and tests
ctx := context.Background()
// For when you're unsure which context to use (temporary placeholder)
ctx := context.TODO()
Both are functionally identical—empty contexts that are never cancelled. The difference is purely semantic and documentary: TODO() means "I haven't decided what context should go here; I'll come back to fix this."
WithCancel: Manual Cancellation
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // Ensure resources released on function exit
go worker(ctx)
time.Sleep(3 * time.Second)
cancel() // Signal worker to exit
time.Sleep(1 * time.Second) // Give worker time to clean up
}
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("worker stopped:", ctx.Err())
return
default:
fmt.Println("working...")
time.Sleep(500 * time.Millisecond)
}
}
}
WithCancel returns a new context and a cancel function. Calling cancel() will:
- Close that context's
Done()channel - Close all child contexts'
Done()channels (cascading cancellation) - Set
Err()to returncontext.Canceled
Key rule: If you create a cancel function, you must call it. Not calling it leaks resources—internal goroutines and channels won't be freed. defer cancel() is the safest pattern.
WithTimeout and WithDeadline: Automatic Timeout
// WithTimeout: start timing from now
func fetchData(ctx context.Context) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err // May be timeout: ctx.Err() == context.DeadlineExceeded
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
// WithDeadline: specify absolute time
func processBeforeClose(ctx context.Context) {
deadline := time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC)
ctx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
select {
case <-ctx.Done():
fmt.Println("deadline reached:", ctx.Err())
case result := <-doWork(ctx):
fmt.Println("completed:", result)
}
}
WithTimeout(parent, d) is equivalent to WithDeadline(parent, time.Now().Add(d)).
Timeout nesting rule: a child context's timeout cannot exceed the parent's.
parentCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Child requests 30-second timeout—but will actually be cancelled after 10s
// because parent times out first
childCtx, childCancel := context.WithTimeout(parentCtx, 30*time.Second)
defer childCancel()
WithValue: Passing Request-Scoped Data
type contextKey string
const (
keyRequestID contextKey = "request_id"
keyUserID contextKey = "user_id"
)
func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := uuid.New().String()
ctx := context.WithValue(r.Context(), keyRequestID, requestID)
ctx = context.WithValue(ctx, keyUserID, getUserFromAuth(r))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func handler(w http.ResponseWriter, r *http.Request) {
requestID := r.Context().Value(keyRequestID).(string)
userID := r.Context().Value(keyUserID).(string)
log.Printf("[%s] user %s requested data", requestID, userID)
}
Use custom types as keys (avoid collisions):
// Wrong: string keys may collide with other packages
ctx = context.WithValue(ctx, "user_id", "123")
// Correct: unexported custom type guarantees uniqueness
type contextKey string
const keyUserID contextKey = "user_id"
ctx = context.WithValue(ctx, keyUserID, "123")
Golden Rules for Context Propagation
- Pass Context as the first parameter:
// Correct
func FetchUser(ctx context.Context, id string) (*User, error)
// Wrong: don't put context in a struct
type Client struct {
ctx context.Context // Don't do this!
}
- Don't pass nil context:
// If unsure, use context.TODO()
func doSomething(ctx context.Context) {
if ctx == nil {
ctx = context.TODO() // But better to pass correct context from caller
}
}
- Same context can be passed to multiple goroutines:
func processAll(ctx context.Context, items []Item) {
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(it Item) {
defer wg.Done()
process(ctx, it) // Same ctx; all goroutines get signal on cancel
}(item)
}
wg.Wait()
}
Common Mistakes
Mistake 1: Ignoring context cancellation signal
// Wrong: accepts ctx but never checks it
func badWorker(ctx context.Context) {
for i := 0; i < 1000000; i++ {
heavyComputation(i) // Runs all iterations even if ctx is cancelled
}
}
// Correct: periodically check in long loops
func goodWorker(ctx context.Context) error {
for i := 0; i < 1000000; i++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
heavyComputation(i)
}
return nil
}
Mistake 2: Cancelling at the wrong level
// Wrong: creating WithCancel inside loop, cancel never called
func bad(ctx context.Context) {
for i := 0; i < 100; i++ {
ctx, _ := context.WithCancel(ctx) // Leak! cancel discarded
go doWork(ctx)
}
}
// Correct: save cancel and call at appropriate time
func good(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
for i := 0; i < 100; i++ {
go doWork(ctx)
}
}
Mistake 3: Using WithValue for non-request-scoped data
// Wrong: database connection isn't request-scoped
ctx = context.WithValue(ctx, "db", dbConn) // Don't!
// Wrong: optional parameters shouldn't go through context
ctx = context.WithValue(ctx, "verbose", true) // Don't!
// Correct WithValue usage:
// - Request ID
// - Authentication info (user ID, token)
// - Distributed tracing span
// - Request locale/language
Practical Example: HTTP Middleware Chain
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/users", getUsers)
// Middleware chain: logging -> timeout -> auth -> handler
handler := loggingMiddleware(
timeoutMiddleware(5*time.Second,
authMiddleware(mux)))
http.ListenAndServe(":8080", handler)
}
func timeoutMiddleware(timeout time.Duration, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
// Replace request's context with new one
r = r.WithContext(ctx)
done := make(chan struct{})
go func() {
next.ServeHTTP(w, r)
close(done)
}()
select {
case <-done:
// Handler completed normally
case <-ctx.Done():
// Timeout
http.Error(w, "request timeout", http.StatusGatewayTimeout)
}
})
}
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
user, err := validateToken(token)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), keyUser, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Level 2: How It Works Under the Hood
Context's Complete Lifecycle in HTTP Requests
When an HTTP request arrives at a Go server:
Client request -> net/http.Server
-> Server creates context for request (based on connection's BaseContext)
-> Request object r.Context() returns this context
-> If client disconnects, context is cancelled
-> Handler gets context via r.Context()
-> Handler passes context to downstream calls
// net/http internals (simplified)
func (srv *Server) Serve(l net.Listener) error {
baseCtx := context.Background()
if srv.BaseContext != nil {
baseCtx = srv.BaseContext(l)
}
for {
conn, _ := l.Accept()
connCtx := srv.ConnContext(baseCtx, conn) // Customizable
go srv.serveConn(connCtx, conn)
}
}
func (c *conn) serve(ctx context.Context) {
// Cancel context when connection closes
ctx, cancelCtx := context.WithCancel(ctx)
defer cancelCtx()
// ... read request ...
req.ctx = ctx // Request bound to connection's context
}
Key point: When a client drops the TCP connection, net/http automatically cancels the request's context. This means if your handler is doing an expensive operation (like a database query), you can check ctx.Done() to terminate early rather than completing work the client no longer needs.
Context and Goroutine Lifecycle Management
Context's greatest value is managing goroutine lifecycles—particularly "cascading cancellation."
func handleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Launch 3 parallel data fetches
userCh := make(chan *User, 1)
ordersCh := make(chan []*Order, 1)
recsCh := make(chan []*Recommendation, 1)
go func() {
user, _ := fetchUser(ctx, r.URL.Query().Get("id"))
userCh <- user
}()
go func() {
orders, _ := fetchOrders(ctx, r.URL.Query().Get("id"))
ordersCh <- orders
}()
go func() {
recs, _ := fetchRecommendations(ctx, r.URL.Query().Get("id"))
recsCh <- recs
}()
// Wait for all results or context cancellation
select {
case <-ctx.Done():
// Client disconnected or timeout—all 3 goroutines receive cancel signal
http.Error(w, "request cancelled", http.StatusServiceUnavailable)
return
case user := <-userCh:
// ... continue waiting for other results
_ = user
}
}
When the request is cancelled, ctx.Done() closes, and the same ctx passed to fetchUser, fetchOrders, fetchRecommendations also "senses" the cancellation. If these functions are making HTTP requests or database queries internally (and correctly use ctx), they'll terminate early.
Context Tree Structure
Contexts form a tree—each With* call creates a child node:
Background
+-- WithCancel (request A)
| +-- WithTimeout (DB query, 5s)
| +-- WithTimeout (external API call, 10s)
| | +-- WithValue (trace span)
| +-- WithValue (user info)
+-- WithCancel (request B)
+-- WithTimeout (processing timeout, 30s)
Cancellation propagates top-down: cancelling a node cancels all its descendants, but doesn't affect siblings or ancestors.
Context Internal Implementation
The standard library has several context implementations:
// emptyCtx: implementation of Background() and TODO()
type emptyCtx struct{}
func (emptyCtx) Deadline() (time.Time, bool) { return time.Time{}, false }
func (emptyCtx) Done() <-chan struct{} { return nil } // Never cancelled
func (emptyCtx) Err() error { return nil }
func (emptyCtx) Value(key any) any { return nil }
// cancelCtx: implementation of WithCancel
type cancelCtx struct {
Context // Embeds parent context
mu sync.Mutex
done atomic.Value // chan struct{}, lazily created
children map[canceler]struct{} // Child context set
err error
}
// timerCtx: implementation of WithTimeout/WithDeadline
type timerCtx struct {
*cancelCtx
timer *time.Timer
deadline time.Time
}
// valueCtx: implementation of WithValue
type valueCtx struct {
Context // Embeds parent context
key, val any
}
WithValue lookup is linked-list traversal:
func (c *valueCtx) Value(key any) any {
if c.key == key {
return c.val
}
return value(c.Context, key) // Recursively search up
}
This means Value's time complexity is O(n), where n is the path length from current node to root. Don't store many values in the context chain—it's not meant to be a map.
Cancel Propagation Mechanism
When cancel() is called:
func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
c.mu.Lock()
if c.err != nil {
c.mu.Unlock()
return // Already cancelled
}
c.err = err
c.cause = cause
// Close done channel
d, _ := c.done.Load().(chan struct{})
if d == nil {
c.done.Store(closedchan) // Reuse a pre-closed global channel
} else {
close(d)
}
// Cascade cancel to all children
for child := range c.children {
child.cancel(false, err, cause)
}
c.children = nil
c.mu.Unlock()
if removeFromParent {
removeChild(c.Context, c) // Remove self from parent
}
}
Note the closedchan optimization: if nobody called Done() before cancellation (nobody was waiting for cancel signal), there's no need to create and close a real channel—just use a pre-closed global channel.
WithTimeout Implementation
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
// If parent's deadline is earlier, just create cancelCtx
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
return WithCancel(parent)
}
c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: d,
}
propagateCancel(parent, c) // Register as parent's child
dur := time.Until(d)
if dur <= 0 {
c.cancel(true, DeadlineExceeded, nil) // Already expired
return c, func() { c.cancel(false, Canceled, nil) }
}
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded, nil)
})
return c, func() { c.cancel(true, Canceled, nil) }
}
Why call cancel even after timeout? time.AfterFunc creates a Timer. If the operation completes before timeout, calling cancel stops the Timer and releases associated resources. Without calling it, the Timer lives until timeout—not functionally wrong, but wastes a small amount of memory.
Context and Database Operations
func queryUsers(ctx context.Context, db *sql.DB) ([]User, error) {
// database/sql natively supports context
rows, err := db.QueryContext(ctx, "SELECT id, name FROM users WHERE active = true")
if err != nil {
return nil, err
}
defer rows.Close()
var users []User
for rows.Next() {
// If ctx is cancelled, rows.Next() returns false
var u User
if err := rows.Scan(&u.ID, &u.Name); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
database/sql introduced Context method variants in Go 1.8 (QueryContext, ExecContext, etc.). When context is cancelled:
- If query hasn't been sent to database yet—returns error immediately
- If query is executing—sends cancel signal to database (MySQL's
KILL QUERY) - If results are being read—stops reading, closes connection
gRPC and Context
gRPC is another heavy user of context. Client-set deadlines propagate to the server via gRPC protocol:
// Client
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, &pb.UserRequest{Id: "123"})
if err != nil {
if status.Code(err) == codes.DeadlineExceeded {
log.Println("server took too long")
}
}
// Server
func (s *server) GetUser(ctx context.Context, req *pb.UserRequest) (*pb.UserResponse, error) {
// ctx already carries client-set deadline!
user, err := s.db.QueryContext(ctx, "SELECT * FROM users WHERE id = ?", req.Id)
if err != nil {
return nil, err
}
return &pb.UserResponse{User: user}, nil
}
Deadlines propagate via gRPC metadata: the client calculates remaining time, encodes it in the grpc-timeout header, and the server uses it to set the local context's deadline.
Graceful Shutdown
func main() {
// Create root context for shutdown signal
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: mux}
// Start server in background
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()
// Wait for shutdown signal
<-ctx.Done()
log.Println("shutting down...")
// Give in-flight requests 30 seconds to complete
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatal("forced shutdown:", err)
}
log.Println("server stopped gracefully")
}
Note: Shutdown uses a new context.Background()-based context, not the already-cancelled ctx—because we need to give Shutdown a fresh timeout period.
Level 3: What the Specification Says
Context Design Background and Controversies
The context package design comes from Google's internal practices. Sameer Ajmani first publicly introduced it in the 2014 blog post "Go Concurrency Patterns: Context." It solved a real problem in Google's large-scale microservice architecture: a single user request might trigger dozens of RPCs, each potentially triggering more RPCs, forming a call tree. When a user cancels the request, the entire call tree needs to know.
Controversy 1: Should Context be passed explicitly or implicitly?
Go chose explicit passing (as first parameter), rather than implicitly associating with threads like Java's ThreadLocal. Rationale:
- Explicit passing makes dependencies visible—function signature immediately shows it supports cancellation
- Goroutines aren't bound to threads;
ThreadLocalsemantics don't work in Go - Explicit passing is easy to test—pass different contexts to simulate different scenarios
Controversy 2: Is WithValue good design?
This is the most controversial part of the context package. Critics (including Dave Cheney, "Context is for cancelation" 2017) argue:
- WithValue is essentially an untyped, immutable global variable
- It bypasses the function signature's type system
- It makes dependencies invisible (you don't know what values a function needs from context)
Supporters argue:
- Request-scoped data (trace ID, auth token) genuinely needs a way to cross API boundaries
- The alternative (putting all data in function parameters) is impractical in deep call chains
- WithValue usage should be strictly limited to request-scoped data
Controversy 3: Is Context as first parameter the right choice?
Russ Cox discussed an alternative in Go issue #28342: putting context in the method's receiver. Ultimately not adopted because:
- Not all functions have receivers
- Consistency is more important than perfection—all functions follow the same convention
Context Evolution in Go Standard Library
| Version | Change |
|---|---|
| Go 1.7 | context package moved from golang.org/x/net/context into stdlib |
| Go 1.8 | database/sql adds Context methods |
| Go 1.14 | os/signal.NotifyContext |
| Go 1.16 | signal.NotifyContext simplifies signal handling |
| Go 1.20 | context.WithCancelCause allows passing cancellation reason |
| Go 1.21 | context.WithoutCancel creates child that won't be cancelled by parent |
| Go 1.21 | context.AfterFunc registers cancellation callback |
Go 1.20: WithCancelCause
ctx, cancel := context.WithCancelCause(parent)
// Provide reason when cancelling
cancel(fmt.Errorf("user %s rate limited", userID))
// Get cancellation cause
err := context.Cause(ctx)
fmt.Println(err) // "user xxx rate limited"
This solves a long-standing pain point: previously ctx.Err() only returned context.Canceled; you couldn't know why it was cancelled.
Go 1.21: WithoutCancel
// Creates a child that won't be cancelled by parent
// But still inherits parent's Values
func WithoutCancel(parent Context) Context
Use case: when you need to continue async work after request handler returns (like async logging), but still need the request's trace ID:
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Background task that continues after request returns
bgCtx := context.WithoutCancel(ctx) // Won't be affected by request cancellation
go func() {
// bgCtx still has trace ID and other Values
// But won't be cancelled when request ends
asyncLog(bgCtx, "request processed")
}()
w.WriteHeader(http.StatusOK)
}
Go 1.21: AfterFunc
stop := context.AfterFunc(ctx, func() {
// Executed when ctx is cancelled
cleanup()
})
// If you no longer need this callback:
stop() // Prevent callback execution (if not yet triggered)
This provides a callback mechanism for context cancellation, replacing the "launch a goroutine to watch ctx.Done()" pattern:
// Old pattern: requires spawning goroutine
go func() {
<-ctx.Done()
conn.Close()
}()
// New pattern: more concise, no extra goroutine needed
stop := context.AfterFunc(ctx, func() {
conn.Close()
})
defer stop()
Design Decisions of context.Context Interface
Why does Done() return a channel instead of providing a Wait() method?
Channels work with select, which is Go's core concurrency pattern:
select {
case <-ctx.Done():
return ctx.Err()
case result := <-ch:
return result, nil
}
With a Wait() method, you couldn't compose with other channels—you'd need a separate goroutine to call Wait(), making code more complex.
Why is Value's key interface{} instead of string?
To avoid key collisions between different packages. If keys were strings, two unrelated packages might use the same key (like "user") to store different values. Using unexported custom types as keys guarantees namespace isolation:
// package auth
type contextKey struct{}
var userKey = contextKey{}
// package logging
type contextKey struct{} // Different type, even with same name no collision
var userKey = contextKey{}
Comparison with Other Languages' Cancellation Mechanisms
| Feature | Go context | Java CancellationToken | C# CancellationToken | Rust tokio |
|---|---|---|---|---|
| Passing | Explicit parameter | Explicit parameter | Explicit parameter | Implicit (task local) |
| Cancellation propagation | Tree cascade | Chained | Chained | Tree |
| Value passing | WithValue | ThreadLocal | AsyncLocal | task_local! |
| Timeout | WithTimeout | External Timer | WithTimeout | tokio::time::timeout |
| Sync/Async | Sync (Done channel) | Async (callback) | Sync (WaitHandle) | Async |
Go's design most closely resembles C#'s CancellationToken—both use explicit passing with cascading cancellation. The main difference is Go uses channels for waiting while C# uses WaitHandle/callbacks.
Level 4: Edge Cases and Pitfalls
Pitfall 1: Don't Put Context in a Struct
This is the most emphasized rule in context's official documentation:
// Wrong
type Server struct {
ctx context.Context
// ...
}
// Wrong
type Request struct {
ctx context.Context
url string
method string
}
Why?
-
Lifecycle mismatch: Structs are typically long-lived; contexts are short-lived. Storing context in a struct obscures "which operation does this context represent."
-
Ambiguity: If a struct has a ctx field and methods also accept ctx parameters, which one to use?
-
Cannot update: Once stored in a struct, you can't replace it with a child context deeper in the call chain (e.g., adding a timeout).
// Correct approach: each method accepts context parameter
type Server struct {
db *sql.DB
}
func (s *Server) HandleRequest(ctx context.Context, req *Request) error {
// ctx represents this specific request's lifecycle
return s.db.QueryContext(ctx, "...")
}
Only exception: http.Request stores context in a struct, but this is for historical reasons (context was introduced after Request), and http.Request itself is a request-scoped object.
Pitfall 2: WithValue Only for Request-Scoped Data
What is request-scoped data? Data bound to a single request's lifecycle—created when request starts, no longer needed when request ends.
// Correct WithValue usage
ctx = context.WithValue(ctx, traceIDKey, "abc-123") // Trace ID
ctx = context.WithValue(ctx, userKey, authenticatedUser) // Authenticated user
ctx = context.WithValue(ctx, spanKey, opentelemetry.Span{}) // Tracing span
// Wrong WithValue usage
ctx = context.WithValue(ctx, "db", database) // Not request-scoped
ctx = context.WithValue(ctx, "logger", logger) // Should use dependency injection
ctx = context.WithValue(ctx, "config", appConfig) // Should be parameter or struct field
ctx = context.WithValue(ctx, "retries", 3) // Should be function parameter
Rule of thumb: If the data is the same across different requests, it's not request-scoped data.
Pitfall 3: Context Leaks
Every WithCancel/WithTimeout/WithDeadline creates internal goroutines or timers. If cancel isn't called, these resources won't be freed:
// Leak example
func leak(ctx context.Context) {
ctx, _ = context.WithTimeout(ctx, 5*time.Second) // cancel discarded!
// Timer won't clean up for 5 seconds
// If this function is called frequently, timers accumulate
}
// Correct
func noLeak(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() // Released immediately on function exit
}
go vet detection: Since Go 1.16, go vet warns about unused cancel functions.
Pitfall 4: Context.Value Performance
Value() lookup traverses the context chain linearly:
// Lookup path: current -> parent -> grandparent -> ... -> Background
func value(c Context, key any) any {
for {
switch ctx := c.(type) {
case *valueCtx:
if key == ctx.key {
return ctx.val
}
c = ctx.Context
case *cancelCtx:
c = ctx.Context
case *timerCtx:
c = ctx.cancelCtx.Context
case emptyCtx:
return nil
default:
return c.Value(key)
}
}
}
If the context chain is deep (e.g., 20 middleware layers each adding a Value), the deepest Value lookup traverses 20 nodes.
Mitigation strategies:
- Reduce number of Values: Pack multiple values into one struct
// Bad: 3 WithValue calls, search depth increases by 3
ctx = context.WithValue(ctx, requestIDKey, reqID)
ctx = context.WithValue(ctx, userKey, user)
ctx = context.WithValue(ctx, spanKey, span)
// Better: 1 WithValue call, pack all request metadata
type RequestMetadata struct {
RequestID string
User *User
Span trace.Span
}
ctx = context.WithValue(ctx, metadataKey, &RequestMetadata{
RequestID: reqID,
User: user,
Span: span,
})
- Cache Value results on hot paths
func handler(ctx context.Context) {
// One lookup, multiple uses
meta := ctx.Value(metadataKey).(*RequestMetadata)
// ... use meta multiple times within function, instead of calling ctx.Value() each time
}
Pitfall 5: Cleanup Time After Cancellation
Cancellation is immediate, but sometimes you need to give goroutines time to clean up:
func worker(ctx context.Context) error {
for {
select {
case <-ctx.Done():
// Cancelled, but need time to clean up
cleanupCtx, cancel := context.WithTimeout(
context.Background(), // Note: use Background, not cancelled ctx
5*time.Second,
)
defer cancel()
return cleanup(cleanupCtx)
case task := <-taskCh:
process(task)
}
}
}
Note: the cleanup context must be based on context.Background(), not the already-cancelled ctx—otherwise cleanup operations would also be immediately cancelled.
Pitfall 6: ctx.Done() Priority in select
// Problem: if ctx is cancelled but ch also has data ready, select picks randomly
for {
select {
case <-ctx.Done():
return ctx.Err()
case v := <-ch:
process(v)
}
}
If you need to ensure cancellation signal gets priority:
for {
select {
case <-ctx.Done():
return ctx.Err()
case v := <-ch:
// Check again before processing
select {
case <-ctx.Done():
return ctx.Err()
default:
}
process(v)
}
}
Pitfall 7: Context Immutability
Context is immutable—each With* call creates a new context, leaving the original unchanged:
func middleware(ctx context.Context) context.Context {
ctx2 := context.WithValue(ctx, keyA, "value")
// ctx is unchanged!
fmt.Println(ctx.Value(keyA)) // nil
fmt.Println(ctx2.Value(keyA)) // "value"
return ctx2
}
This means you must return the new context (or pass it to subsequent calls), otherwise modifications are invisible:
// Wrong: middleware modifies ctx but doesn't pass it back
func bad(ctx context.Context) {
enrichContext(ctx) // WithValue inside creates new ctx, nobody uses it
doWork(ctx) // Uses original ctx, no new values
}
// Correct
func good(ctx context.Context) {
ctx = enrichContext(ctx) // Use returned new ctx
doWork(ctx)
}
Real-World Case: Context Usage in Kubernetes
The context chain for a request in Kubernetes API Server:
// 1. Server creates request context
ctx := request.WithRequestInfo(req.Context(), requestInfo)
// 2. Auth middleware adds user info
ctx = request.WithUser(ctx, user)
// 3. Audit middleware adds audit ID
ctx = audit.WithAuditID(ctx, auditID)
// 4. Timeout middleware sets request timeout
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
// 5. Inside handler: uses context when creating watch
watcher, err := storage.Watch(ctx, key, opts)
// When request is cancelled, watcher automatically stops
Kubernetes also defines wait.PollUntilContextCancel to replace the old wait.Poll:
// Old API (deprecated)
err := wait.Poll(interval, timeout, conditionFunc)
// New API (context-aware)
err := wait.PollUntilContextCancel(ctx, interval, immediate, conditionFunc)
Interview Questions
-
Difference between context.Background() and context.TODO()?
- Functionally identical, semantically different. Background for root nodes, TODO is "placeholder, fix later"
-
Why shouldn't you put Context in a struct?
- Lifecycle mismatch, ambiguity about which ctx to use, can't replace in call chain
-
After WithTimeout times out, do you still need to call cancel?
- Yes. Calling cancel when completing before timeout releases timer resources early
-
What's the lookup complexity of context.Value?
- O(n), where n is context chain depth (linear traversal to root)
-
How to do cleanup work after cancellation?
- Create new timeout context based on context.Background(), can't reuse cancelled ctx
-
What should/shouldn't WithValue store?
- Should: request ID, auth info, trace span
- Shouldn't: DB connections, logger, config, function parameters
Summary
Context is the infrastructure of Go server-side programming—it solves the most fundamental problems in distributed systems: cancellation propagation and request-scoped data passing.
Core points:
- Contexts form a tree structure; cancellation cascades top-down
- Always pass context, never store in structs
defer cancel()is non-negotiable—every WithCancel/WithTimeout must have a corresponding cancel call- WithValue is only for request-scoped data, not a general KV store
- Context is immutable—With* returns new context, doesn't modify the original
- Use Background()-based new context for cleanup operations
- Understand that ctx.Done() has no priority in select—implement manually if needed
Correct context usage is an essential skill for writing good Go services. It's not complex, but requires discipline—pass ctx to every function, defer every cancel, check Done() in every long operation.