Context Propagation in Go: Designing Cancelable Systems
Go’s context package is the backbone of cancellation, timeouts, and request-scoped data. It looks simple — but using it correctly in a complex system requires discipline.
The Context Chain
Every incoming request creates a context chain:
context.Background()
└── WithTimeout(30s) ← HTTP server request timeout
└── WithValue(userID) ← Middleware adds user info
└── WithTimeout(5s) ← Service call timeout
└── WithTimeout(2s) ← Database query timeout
The tightest timeout wins. If the DB query takes 3s, it’ll be cancelled at 2s. If the entire request takes 25s, everything is cancelled at 30s.
Rule: Always Pass Context Down
// GOOD: context flows from handler to service to repo
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
order, err := h.service.GetOrder(r.Context(), orderID)
// ...
}
func (s *Service) GetOrder(ctx context.Context, id string) (*Order, error) {
return s.repo.FindByID(ctx, id)
}
func (r *Repo) FindByID(ctx context.Context, id string) (*Order, error) {
return r.db.QueryRow(ctx, "SELECT ... WHERE id = $1", id).Scan(...)
}
Never store context in a struct. Never use context.Background() in a function that receives a context parameter. Always pass it as the first argument.
Timeout Chains
Layer timeouts from outer (generous) to inner (strict):
func (s *Service) ProcessOrder(ctx context.Context, order Order) error {
// Overall operation: 30s
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// Validate inventory: 5s
invCtx, invCancel := context.WithTimeout(ctx, 5*time.Second)
defer invCancel()
if err := s.inventory.Check(invCtx, order.Items); err != nil {
return fmt.Errorf("inventory check: %w", err)
}
// Charge payment: 10s
payCtx, payCancel := context.WithTimeout(ctx, 10*time.Second)
defer payCancel()
if err := s.payment.Charge(payCtx, order.Total); err != nil {
return fmt.Errorf("payment: %w", err)
}
// Send confirmation: 5s
notifCtx, notifCancel := context.WithTimeout(ctx, 5*time.Second)
defer notifCancel()
if err := s.notifier.Send(notifCtx, order); err != nil {
// Non-critical — log and continue
slog.Warn("notification failed", "order_id", order.ID, "error", err)
}
return nil
}
Each sub-operation has its own budget, but all are bounded by the 30s parent.
Cancellation Propagation
When a parent context cancels, all children cancel too. This is how you clean up complex operations:
func (s *Service) StreamUpdates(ctx context.Context, userID string) error {
g, ctx := errgroup.WithContext(ctx)
// If any goroutine fails, ctx cancels, and all others stop
g.Go(func() error {
return s.watchOrders(ctx, userID)
})
g.Go(func() error {
return s.watchNotifications(ctx, userID)
})
g.Go(func() error {
return s.watchMessages(ctx, userID)
})
return g.Wait()
}
If the HTTP connection drops, r.Context() cancels, which cancels the errgroup context, which stops all three watchers. No cleanup code needed — it’s built into the context chain.
Context Values: Use Sparingly
Context values are for request-scoped data that crosses API boundaries:
type contextKey string
const (
correlationIDKey contextKey = "correlation_id"
userIDKey contextKey = "user_id"
)
// Middleware sets values
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := validateToken(r.Header.Get("Authorization"))
ctx := context.WithValue(r.Context(), userIDKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Deep in the call stack, retrieve without parameter drilling
func (r *Repo) AuditLog(ctx context.Context, action string) {
userID, _ := ctx.Value(userIDKey).(string)
r.db.Exec(ctx, "INSERT INTO audit_log (user_id, action) VALUES ($1, $2)",
userID, action)
}
Rules for context values:
- Use custom types as keys (not strings) to avoid collisions
- Only for request-scoped data (correlation IDs, auth info, tracing spans)
- Never for optional function parameters
- Never for data that should be in function signatures
Handling Cancellation Errors
Check context errors to distinguish timeouts from cancellations:
func (s *Service) DoWork(ctx context.Context) error {
result, err := s.client.Call(ctx)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("operation timed out: %w", err)
}
if ctx.Err() == context.Canceled {
// Client disconnected — not really an error
return nil
}
return err
}
return process(result)
}
Don’t log client cancellations as errors — they’re normal (user closed browser, mobile app went to background).
AfterFunc (Go 1.21+)
Register cleanup callbacks when context cancels:
func (s *Service) StartProcess(ctx context.Context) (*Process, error) {
proc := startExpensiveProcess()
stop := context.AfterFunc(ctx, func() {
slog.Info("context cancelled, stopping process")
proc.Stop()
})
// If we stop the process normally, cancel the AfterFunc
// so it doesn't fire later
go func() {
proc.Wait()
stop()
}()
return proc, nil
}
Context is Go’s answer to the cancellation problem in concurrent systems. Use it consistently, and your services will clean up gracefully when things go wrong.