All posts
3 min read

Golang Context Patterns Every Backend Engineer Should Know

Context is Go's most underused tool for building reliable services. This post covers cancellation propagation, timeout budgets, and tracing integration.

GolangBackendDistributed Systems

Every Golang service I have reviewed in production either uses context.Context correctly everywhere, or has subtle reliability issues that only surface under load. There is rarely a middle ground.

This post covers the patterns I reach for on every service.

Rule zero: never ignore a context

The most common mistake is accepting a context in a handler and then not passing it to downstream calls:

// Bad: the HTTP context is discarded.
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    user, err := h.db.QueryRow(context.Background(), "SELECT ...")
    // ...
}

// Good: the request's deadline and cancellation propagate.
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    user, err := h.db.QueryRow(r.Context(), "SELECT ...")
    // ...
}

When a client disconnects mid-request, r.Context() is cancelled. If you have passed it all the way down, your database query is cancelled too — freeing the connection immediately instead of running to completion for no one.

Timeout budgets with context.WithTimeout

For outbound calls — databases, downstream services, caches — always set an explicit timeout budget. The pattern I use is a helper that wraps the call and sets the budget:

func (c *Client) FetchProfile(ctx context.Context, userID string) (*Profile, error) {
    ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
    defer cancel()

    return c.grpcClient.GetProfile(ctx, &pb.ProfileRequest{UserId: userID})
}

The defer cancel() call is critical — it releases the timer resource even when the call returns before the deadline.

Propagating trace IDs via context values

When running multiple services with distributed tracing, you often need to extract a trace ID from a context to include it in log lines. Define a typed key to avoid collisions:

type contextKey string

const traceIDKey contextKey = "trace_id"

func WithTraceID(ctx context.Context, traceID string) context.Context {
    return context.WithValue(ctx, traceIDKey, traceID)
}

func TraceIDFromContext(ctx context.Context) string {
    v, _ := ctx.Value(traceIDKey).(string)
    return v
}

Using an unexported typed key ensures no other package can accidentally shadow or read the value.

Detecting cancellation in long-running loops

For streaming or batch-processing goroutines, check ctx.Done() at each iteration:

func (w *Worker) Process(ctx context.Context, items []Item) error {
    for _, item := range items {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }
        if err := w.process(ctx, item); err != nil {
            return err
        }
    }
    return nil
}

This ensures graceful shutdown when a deadline is exceeded or when the caller cancels — no goroutine leaks, no wasted CPU.

Summary

These patterns are the minimum bar for any production Golang service:

  1. Always propagate the incoming context — never substitute context.Background() in a handler.
  2. Wrap all outbound calls in context.WithTimeout.
  3. Use typed, unexported keys for context values.
  4. Poll ctx.Done() inside loops and long-running goroutines.

Get these right from day one and a whole class of subtle reliability bugs simply will not occur.