Skip to content
· 9 min read · 0 views

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 out
  • Err() 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:

  1. No single operation monopolizes the request budget
  2. Multiple operations can run in parallel without exceeding the parent timeout
  3. 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:

  1. Stop accepting new requests
  2. Complete in-flight requests (with timeout)
  3. 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

  1. Always propagate context through your call chain — never create a new context.Background() mid-request
  2. Defer cancel() immediately after creating a context with timeout or deadline
  3. Use a timeout hierarchy — HTTP handler > service > repository > external call
  4. Never return a derived context from a function; let the caller provide it
  5. Context values for request-scoped data only — trace IDs, user IDs, auth tokens
  6. Check ctx.Done() in long-running goroutines to avoid leaks
  7. Use errgroup for concurrent operations — it handles cancellation and error aggregation
  8. 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

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.

Discussion