The context Package: Why it’s your best friend in distributed systems and how to use it right

go dev.to

Most Go devs call context.Background() on autopilot and never think twice. But context isn’t just plumbing — it’s the backbone of cancellation, deadlines, and request-scoped values across goroutines and service boundaries. In distributed systems, where one user request fans out across five-plus services, context prevents cascading failures, goroutine leaks, and silent resource exhaustion.

The context API, the patterns that matter for distributed systems, and the pitfalls that trip up even experienced Gophers — code first, opinions second.

Problem: the leaky fan-out

An HTTP handler queries three downstream services in parallel:

func handler(w http.ResponseWriter, r *http.Request) {
 ch := make(chan result, 3)

 go fetch("service-a", ch)
 go fetch("service-b", ch)
 go fetch("service-c", ch)

 // collect results…
}
Enter fullscreen mode Exit fullscreen mode

Then the client disconnects after 200ms. The goroutines still talking to service-b and service-c keep right on running — consuming memory, open connections, and CPU. That’s a goroutine leak, and in production it compounds until the process OOMs.

Context fixes this by giving every goroutine a shared signal to stop. context.Context provides a Done() channel that propagates from parent to child — when the client disconnects, the handler cancels the context and every goroutine listening on ctx.Done() exits immediately.

Context in practice: The core API

ctx := context.Background()
// Root context: no cancel, no deadline, no values.
// Use in main(), init(), or tests.

ctx = context.TODO()
// Placeholder when you're unsure which context to use.

ctx, cancel := context.WithCancel(ctx)
// Manual cancellation.
// Useful for goroutine lifecycle management or early bailout.
defer cancel()

ctx, cancel = context.WithTimeout(ctx, 2*time.Second)
// Automatically cancels after the timeout.
// Most common for HTTP and gRPC requests.
defer cancel()

ctx, cancel = context.WithDeadline(ctx, time.Now().Add(2*time.Second))
// Cancels at a specific point in time.
// Similar to WithTimeout, but uses an absolute deadline.
defer cancel()

ctx = context.WithValue(ctx, traceKey, traceID)
// Attach request-scoped metadata (e.g. trace IDs, auth info,
// feature flags). Avoid using for optional parameters.
Enter fullscreen mode Exit fullscreen mode

Every With* function returns a derived context. When a parent is canceled, all children derived from it are canceled too.

Cancellation tree

The fan-out pattern done right:

func fanOut(ctx context.Context, services []string) []result {
    ch := make(chan result, len(services))
    for _, svc := range services {
        go func(s string) {
            select {
            case ch <- fetch(ctx, s):
            case <-ctx.Done():
                // parent canceled — bail
            }
        }(svc)
    }
    // collect or timeout
    return collect(ctx, ch, len(services))
}
Enter fullscreen mode Exit fullscreen mode

Every select with ctx.Done() follows the same pattern: listen and return early.

HTTP handler with context propagation

func handleSearch(w http.ResponseWriter, r *http.Request) {
 // Request context is already canceled if the client disconnects.
 ctx := r.Context()

 // Add a timeout for downstream work.
 ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
 defer cancel()

 // Attach request-scoped metadata.
 traceID := extractTraceID(r)
 ctx = context.WithValue(ctx, traceKey, traceID)

 // Fan out requests to downstream services.
 results := fanOut(ctx, services)

 // If the client disconnects or the timeout expires,
 // ctx is canceled and all goroutines should exit early.

 _ = results
}
Enter fullscreen mode Exit fullscreen mode

The key insight is that r.Context() is already wired to the client connection — you derive from it rather than starting from scratch.

Retry loop with context

Context-aware retries prevent runaway retry storms:

func retryWithBackoff(
 ctx context.Context,
 maxRetries int,
 fn func() error,
) error {
 for i := 0; i < maxRetries; i++ {
  if err := fn(); err == nil {
   return nil
  }

  select {
  case <-time.After(backoff(i)):
   // Retry after the backoff delay.

  case <-ctx.Done():
   // Stop retrying if the context is canceled
   // or the deadline is exceeded.
   return ctx.Err()
  }
 }

 return fmt.Errorf("max retries exceeded")
}
Enter fullscreen mode Exit fullscreen mode

The select ensures timeout or cancellation cuts through the backoff immediately — without it, the caller waits for every attempt.

Deadline propagation across services

gRPC propagates context deadlines automatically. When an incoming RPC carries a deadline, outgoing RPCs derive from the same context — the deadline is converted to a timeout with elapsed time already deducted. No manual plumbing required.

WithValue: the right way

context.WithValue is for request-scoped metadata that crosses process boundaries, not for optional parameters:

  • Trace IDs

  • Auth principals

  • Request-scoped feature flags

Collision-resistant key pattern:

// package trace
type traceKeyType struct{}
var traceKey = traceKeyType{}

func WithTraceID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, traceKey, id)
}

func TraceIDFromContext(ctx context.Context) (string, bool) {
    id, ok := ctx.Value(traceKey).(string)
    return id, ok
}
Enter fullscreen mode Exit fullscreen mode

An unexported key type prevents collisions — never use strings or built-in types as keys.

Pros & Cons

Conclusion

Context is for cancellation first, metadata second, and nothing third. Every goroutine that performs I/O needs to listen on ctx.Done() — that’s the rule with no exceptions. Use WithValue sparingly and only for cross-boundary metadata like trace IDs or auth principals, and never store contexts in structs (the sole exception is backwards compatibility, as in http.Request).

Rule of thumb: if you’re putting something in context because passing it as a parameter is inconvenient, you’re doing it wrong.

References

Source: dev.to

arrow_back Back to Tutorials