Skip to main content

The Go context Package: Cancellation, Timeouts, and Propagation in Production

·8 mins
Table of Contents
The context package exists for one primary reason: goroutine lifecycle management. It gives you a standard, composable way to propagate cancellation signals, deadlines, and request-scoped metadata across API boundaries. Understanding how it works in production is the difference between a service that drains cleanly and one that leaks goroutines under load.

Every non-trivial Go service uses context. You pass it from HTTP handlers to database queries, from gRPC interceptors to downstream API calls. But many engineers treat it as little more than a convention to satisfy function signatures. This post covers how context actually works, where it fails silently when misused, and the patterns that matter in production.

What context Does (and Does Not Do)
#

The context.Context interface has four methods:

context interface
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

These map to three distinct capabilities:

  1. Cancellation: a channel (Done()) that closes when the context is cancelled. Downstream work checks this and stops.
  2. Deadlines / timeouts: a point in time after which Done() closes automatically and Err() returns context.DeadlineExceeded.
  3. Request-scoped values: an immutable key-value store threaded through the call chain. This is the most misused feature.

Context is not a general-purpose communication mechanism. Use channels for that. Context is the plumbing that lets a caller say “if I cancel or time out, everything downstream should know about it.”

The Context Key Anti-Pattern
#

The most common mistake when using context.WithValue is using a built-in type (usually string) as the key:

wrong: string key causes collisions
// BAD: any package can read or overwrite this key
ctx = context.WithValue(ctx, "userID", 42)

The Go documentation is explicit: keys must be comparable types that are not exported from any package, to avoid collisions between packages that happen to use the same string. The correct pattern uses an unexported type:

correct: unexported key type
package auth

// contextKey is an unexported type for context keys in this package.
// Using a named type prevents collisions with keys from other packages.
type contextKey int

const (
    userIDKey contextKey = iota
    tenantIDKey
)

func WithUserID(ctx context.Context, id int64) context.Context {
    return context.WithValue(ctx, userIDKey, id)
}

func UserID(ctx context.Context) (int64, bool) {
    id, ok := ctx.Value(userIDKey).(int64)
    return id, ok
}

Because contextKey is an unexported type, no other package can accidentally read or write your values. The typed accessor functions also give you a type-safe API instead of littering .Value() calls and type assertions throughout the codebase.

Goroutine Cancellation with select
#

The single most important pattern for production Go is checking ctx.Done() in goroutines that do long-running work. If you spin up a goroutine and ignore the context, you have a goroutine leak waiting to happen.

goroutine cancellation
func processItems(ctx context.Context, items []string) error {
    results := make(chan string, len(items))

    go func() {
        for _, item := range items {
            results <- expensiveTransform(item)
        }
        close(results)
    }()

    var processed []string
    for {
        select {
        case <-ctx.Done():
            // Caller cancelled or timed out. Stop work and return the error.
            return ctx.Err()
        case result, ok := <-results:
            if !ok {
                // Channel closed: all items processed.
                return nil
            }
            processed = append(processed, result)
        }
    }
}

The select statement races the context cancellation against the work channel. Whichever fires first wins. This is the pattern that separates services that handle load spikes gracefully from ones that pile up goroutines during slow periods.

HTTP Handler Cancellation
#

When a client disconnects mid-request, Go’s net/http package cancels r.Context() automatically. If your handler ignores the context, it continues doing work for a client that is no longer listening.

http handler with cancellation
func searchHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    query := r.URL.Query().Get("q")

    // Pass the request context downstream. If the client disconnects,
    // ctx.Done() closes and the database query returns early.
    results, err := db.SearchProducts(ctx, query)
    if err != nil {
        if errors.Is(err, context.Canceled) {
            // Client disconnected. No need to write a response.
            return
        }
        http.Error(w, "search failed", http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(results)
}
Important

Always propagate the request context from r.Context() into every downstream call. Never create a fresh context.Background() inside a handler for work that belongs to that request.

HTTP Client with Timeout
#

This is probably the most common production use case. Every outbound HTTP call should have a timeout. Without one, a slow downstream service can hold your goroutines indefinitely.

http client with timeout
func fetchUser(ctx context.Context, userID string) (*User, error) {
    // Impose a per-call deadline regardless of the parent context.
    // The shorter of the two (parent deadline or 5s) wins.
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel() // Always call cancel to release resources.

    req, err := http.NewRequestWithContext(ctx, http.MethodGet,
        "https://api.example.com/users/"+userID, nil)
    if err != nil {
        return nil, fmt.Errorf("building request: %w", err)
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("fetching user: %w", err)
    }
    defer resp.Body.Close()

    var user User
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        return nil, fmt.Errorf("decoding response: %w", err)
    }
    return &user, nil
}
Tip

Use http.NewRequestWithContext (not the older req.WithContext) for all new code. It sets the context at construction time, which is the cleaner API.

Warning

Always defer cancel() immediately after context.WithTimeout or context.WithDeadline. If you forget, the context’s internal timer goroutine leaks until the deadline fires on its own.

Database Queries with Context
#

Modern database drivers (database/sql, pgx, go-redis) all accept context. Pass it so that in-flight queries are cancelled when the request times out or the client disconnects.

database with context
func (r *ProductRepository) FindByID(ctx context.Context, id int64) (*Product, error) {
    const query = `SELECT id, name, price, stock FROM products WHERE id = $1`

    var p Product
    err := r.db.QueryRowContext(ctx, query, id).Scan(
        &p.ID, &p.Name, &p.Price, &p.Stock,
    )
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, ErrNotFound
        }
        return nil, fmt.Errorf("querying product %d: %w", id, err)
    }
    return &p, nil
}

When ctx is cancelled, QueryRowContext returns an error wrapping context.Canceled. This propagates back up the call stack, and your handler can decide whether to log it or silently drop it.

WithValue: When to Use It and When Not To
#

context.WithValue is for request-scoped metadata that crosses API boundaries where adding a function parameter is not practical. Examples that make sense:

  • Trace IDs / request IDs for structured logging
  • Authenticated user identity set by middleware
  • Tenant IDs in multi-tenant services

Examples that do not belong in context:

  • Configuration values (use a config struct)
  • Logger instances (inject via struct fields)
  • Database connections (inject via dependency injection)
  • Function parameters (just add the parameter)

The test is simple: if removing the value from context would require adding a parameter to every function in the chain, it might belong there. If it would require adding it to just one function, it does not belong in context.

Note

Context values are not type-safe by default, and they have no visibility in function signatures. Overusing them makes code hard to reason about. Treat WithValue as a last resort for cross-cutting concerns, not a way to avoid threading parameters.

Architecture: Context Propagation in a Request
#

Here is how context flows through a typical three-tier service:

flowchart TD
    A[HTTP Request] -->|r.Context| B[Handler]
    B -->|WithTimeout 5s| C[Service Layer]
    C -->|propagate ctx| D[Repository]
    C -->|propagate ctx| E[Downstream HTTP Call]
    D -->|QueryRowContext| F[(Database)]
    E -->|NewRequestWithContext| G[External API]
    B -->|ctx.Done closes on disconnect| H[Cancelled]
    H -->|error bubbles up| C
    H -->|query returns| D

The handler owns the root context (from r.Context()). Each layer passes it down unchanged, or wraps it with a tighter deadline. When cancellation fires, the signal propagates to every in-flight call simultaneously.

Common Mistakes
#

Using string keys for context values
String keys cause silent collisions between packages. Two packages that both store a value under the key "userID" will overwrite each other. Always use an unexported named type defined in your package as the key type.
Passing nil context
Some older code passes nil as a context argument. This causes a panic at the first context method call. Use context.Background() as the root context in main, tests, and any place where there is no parent context to derive from. Use context.TODO() as a placeholder when you know context needs to be plumbed but have not done it yet.
Ignoring ctx.Done() in goroutines
Launching a goroutine and never checking ctx.Done() means the goroutine runs to completion even when the caller has cancelled. In a high-traffic service, this accumulates as goroutine leaks that show up as memory growth and increasing latency over time.
Creating context.Background() inside handlers
If you create a fresh background context inside an HTTP handler for an outbound call, you detach that call from the request lifecycle. A client disconnect or server timeout will no longer cancel the outbound work. Always derive from r.Context().
Storing context in a struct
The Go team explicitly discourages storing a context.Context as a struct field. Contexts are meant to flow through function call chains, not be kept as state. If you find yourself storing one, it usually signals a design problem.

If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.

Related