Go Context: Beyond Cancellation
Understanding context.Context in Go — timeouts, deadlines, request-scoped values, and cancellation propagation patterns for production backend services.
// table of contents (17 sections)
Every Go tutorial covers context.Context for cancellation. You pass it to your HTTP handlers, maybe to database calls, and call it a day. But in production backend services, context is so much more — it is the backbone of request lifecycle management, timeout enforcement, and clean shutdown.
This post covers the patterns I have learned building production Go services that handle millions of requests: when to create contexts, how to structure timeouts, and the subtle bugs that bite when you misuse context.
What Context Actually Is
At its core, context.Context is an immutable tree of scoped values and cancellation signals. Three operations matter:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Done()returns a channel that closes when the context is cancelled or times outErr()returns why the context was cancelled (canceled, deadline exceeded)Deadline()returns when the context will expire (if set)Value()retrieves request-scoped values
The key insight: contexts form a tree. When a parent context is cancelled, all its children are cancelled too. This is what makes cancellation propagate through call chains.
Creating Contexts: The Right Way
Background Context
For background operations or goroutines not tied to a request:
// Only for operations with no reasonable deadline
ctx := context.Background()
TODO Context
When you do not have a context yet but know you will need one:
// Use when you will pass the context to another function
ctx := context.TODO()
This is a code smell. If you see TODO(), it usually means someone was lazy and did not propagate context properly.
With Timeout
The most common pattern for API calls:
func (r *Repository) FetchUser(ctx context.Context, id string) (*User, error) {
// Create a child context with timeout
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// Now any operation using ctx will timeout after 5 seconds
row := r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", id)
// ... scan and return
}
Always defer cancel(). Even if the operation completes successfully, canceling releases resources associated with the context.
With Deadline
When you need a specific deadline instead of a relative timeout:
func (s *Service) ProcessBatch(ctx context.Context) error {
// Deadline at midnight
deadline := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour)
ctx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
return s.processor.Run(ctx)
}
With Value
For request-scoped data like trace IDs, user IDs, or auth tokens:
type contextKey string
const (
userIDKey contextKey = "userID"
traceIDKey contextKey = "traceID"
)
func Middleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-ID")
ctx := context.WithValue(r.Context(), userIDKey, userID)
h.ServeHTTP(w, r.WithContext(ctx))
})
}
// Later, in your handler
func GetUserID(ctx context.Context) string {
if userID, ok := ctx.Value(userIDKey).(string); ok {
return userID
}
return ""
}
Important: Use custom types for keys, not strings. This prevents collisions between packages.
Timeout Hierarchy in Production Services
One of the biggest mistakes I see is setting a single timeout at the HTTP handler level. This causes problems: too long, and slow clients hold resources; too short, and legitimate requests fail.
The solution is a timeout hierarchy:
┌─────────────────────────────────────────────────────────────┐
│ HTTP Handler Timeout: 30s (overall request budget) │
├─────────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Service Layer: 25s (business logic timeout) │ │
│ ├────────────────────────────────────────────────────────┤ │
│ │ ┌──────────────────────────────────────────────────┐ │ │
│ │ │ Database Query: 5s (single query timeout) │ │ │
│ │ ├──────────────────────────────────────────────────┤ │ │
│ │ │ External API: 10s (third-party service timeout) │ │ │
│ │ └──────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Each layer has its own timeout, shorter than its parent. This ensures that:
- No single operation monopolizes the request budget
- Multiple operations can run in parallel without exceeding the parent timeout
- Failures are fast and specific
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
// Handler-level timeout: 30s
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
user, err := h.userService.GetUser(ctx, r.PathValue("id"))
if err != nil {
h.handleError(w, err)
return
}
json.NewEncoder(w).Encode(user)
}
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
// Service-level timeout: 25s (shorter than handler)
ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
// Fetch user and profile concurrently
var user *User
var profile *Profile
var err error
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
user, err = s.repo.GetUser(ctx, id)
return err
})
g.Go(func() error {
profile, err = s.repo.GetProfile(ctx, id)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
user.Profile = profile
return user, nil
}
func (r *Repository) GetUser(ctx context.Context, id string) (*User, error) {
// Query-level timeout: 5s
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// Database operation respects ctx timeout
row := r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", id)
// ... scan and return
}
Cancellation Gotchas
The Returning Context Error
This is one of the most common bugs:
// BAD: Returns a cancelled context
func (s *Service) ProcessData() (*Result, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // Context is cancelled here!
result, err := s.process(ctx)
if err != nil {
return nil, err
}
// If caller tries to use result.ctx, it's already cancelled
return result, nil
}
Never return a derived context from a function. If the caller needs context, require it as a parameter:
// GOOD: Caller provides the context
func (s *Service) ProcessData(ctx context.Context) (*Result, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return s.process(ctx)
}
The Goroutine Leak
Forgetting to handle Done() causes goroutine leaks:
// BAD: Goroutine never exits
func (s *Service) ProcessAsync(ctx context.Context, data string) {
go func() {
// No select on ctx.Done()
time.Sleep(10 * time.Second)
s.db.Save(data)
}()
}
// GOOD: Respects cancellation
func (s *Service) ProcessAsync(ctx context.Context, data string) {
go func() {
select {
case <-ctx.Done():
return // Exit when context cancelled
case <-time.After(10 * time.Second):
s.db.Save(data)
}
}()
}
Better yet, use errgroup which handles cancellation automatically:
func (s *Service) ProcessBatch(ctx context.Context, items []string) error {
g, ctx := errgroup.WithContext(ctx)
for _, item := range items {
item := item // Capture loop variable
g.Go(func() error {
return s.processItem(ctx, item)
})
}
return g.Wait()
}
The Nil Context Mistake
Passing nil instead of a context breaks the cancellation chain:
// BAD: Breaks cancellation propagation
func (r *Repository) FetchData(id string) (*Data, error) {
ctx := context.Background() // Loses caller's context!
return r.query(ctx, id)
}
// GOOD: Accepts context from caller
func (r *Repository) FetchData(ctx context.Context, id string) (*Data, error) {
return r.query(ctx, id)
}
Context Values: Use With Discipline
Context values are convenient but easily abused. They should only contain request-scoped data that cuts across your call chain.
Good use cases:
- Trace IDs, request IDs for logging
- Authentication tokens, user IDs
- Feature flags, request-specific config
- Deadlines and cancellation signals
Bad use cases:
- Database connections (use dependency injection)
- Loggers (use structured logging with context)
- Configuration constants (use a config struct)
// GOOD: Request-scoped tracing
const traceIDKey contextKey = "traceID"
func TracingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
traceID := uuid.New().String()
ctx := context.WithValue(r.Context(), traceIDKey, traceID)
log.Printf("traceID=%s method=%s path=%s", traceID, r.Method, r.Path)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// BAD: Storing a database connection in context
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
db := r.Context().Value("db").(sql.DB) // Don't do this
// ...
}
Structured Logging With Context
Since Go 1.21, the log/slog package makes context-aware logging easy:
import "log/slog"
func (s *Service) ProcessOrder(ctx context.Context, orderID string) error {
slog.InfoContext(ctx, "Processing order",
"order_id", orderID,
"user_id", GetUserID(ctx),
)
// The context propagates trace IDs and request metadata
// automatically to all logs within this request scope
}
For older Go versions, libraries like zap, logrus, or zerolog support context logging:
import "go.uber.org/zap"
func (s *Service) ProcessOrder(ctx context.Context, orderID string) error {
logger := zerolog.Ctx(ctx).With().
Str("order_id", orderID).
Str("user_id", GetUserID(ctx)).
Logger()
logger.Info().Msg("Processing order")
}
Graceful Shutdown Patterns
Context is essential for graceful shutdown. When your service receives a shutdown signal, you want to:
- Stop accepting new requests
- Complete in-flight requests (with timeout)
- Close connections and resources
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
server := &http.Server{Addr: ":8080", Handler: yourHandler}
// Start server in goroutine
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("server error: %v", err)
}
}()
// Wait for shutdown signal
<-ctx.Done()
// Shutdown with timeout
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown error: %v", err)
}
log.Println("Server stopped")
}
Testing With Context
When testing code that uses context, use contexts with short deadlines to test timeout behavior:
func TestSlowQuery(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
// This should timeout
_, err := repo.SlowQuery(ctx)
if err == nil {
t.Fatal("expected timeout error, got nil")
}
if err != context.DeadlineExceeded {
t.Fatalf("expected DeadlineExceeded, got %v", err)
}
}
For testing cancellation:
func TestCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
resultCh := make(chan *Result, 1)
go func() {
resultCh <- slowOperation(ctx)
}()
// Cancel immediately
cancel()
select {
case <-time.After(time.Second):
t.Fatal("operation did not cancel")
case result := <-resultCh:
if result.Error != context.Canceled {
t.Fatalf("expected Canceled error, got %v", result.Error)
}
}
}
Key Takeaways
- Always propagate context through your call chain — never create a new
context.Background()mid-request - Defer
cancel()immediately after creating a context with timeout or deadline - Use a timeout hierarchy — HTTP handler > service > repository > external call
- Never return a derived context from a function; let the caller provide it
- Context values for request-scoped data only — trace IDs, user IDs, auth tokens
- Check
ctx.Done()in long-running goroutines to avoid leaks - Use
errgroupfor concurrent operations — it handles cancellation and error aggregation - Test timeout and cancellation behavior — use short deadlines in tests
Context is simple but powerful. Used correctly, it makes your Go services resilient, cancellable, and production-ready. Misused, it causes leaks and unpredictable behavior. The patterns in this post are the ones I have learned the hard way — hope they save you some debugging time.
You might also like
Error Handling Patterns in Go
Production-ready error handling patterns in Go — wrapping, sentinel errors, custom error types, error groups, and structured logging for backend services.
Go Concurrency: Goroutines and Channels in Practice
Practical goroutines and channels patterns for production Go services — worker pools, pipelines, fan-in/fan-out, rate limiting, and common concurrency pitfalls.
Building RESTful APIs with Go and Chi
A practical guide to building production RESTful APIs with Go and Chi router — routing, middleware, request validation, error handling, and clean architecture.
More Posts
API Gateway Patterns: The Front Door to Your Microservices
Web Components 2026: Building Framework-Agnostic UI Libraries
Building Autonomous AI Workflows with LangGraph: A Practical Guide
Building Type-Safe APIs with tRPC in 2026: Full-Stack TypeScript Without Schemas
Database Connection Pooling: Patterns for High-Performance Applications
Prompt Caching: Reduce LLM Costs by 90% with Smart Context Management
Enjoyed This Post?
Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.
