← back to posts

Context Propagation in Go: Designing Cancelable Systems

Go’s context package is the backbone of cancellation, timeouts, and request-scoped data. It looks simple — but using it correctly in a complex system requires discipline.

The Context Chain

Every incoming request creates a context chain:

context.Background()
  └── WithTimeout(30s)      ← HTTP server request timeout
       └── WithValue(userID)  ← Middleware adds user info
            └── WithTimeout(5s) ← Service call timeout
                 └── WithTimeout(2s) ← Database query timeout

The tightest timeout wins. If the DB query takes 3s, it’ll be cancelled at 2s. If the entire request takes 25s, everything is cancelled at 30s.

Rule: Always Pass Context Down

// GOOD: context flows from handler to service to repo
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
    order, err := h.service.GetOrder(r.Context(), orderID)
    // ...
}

func (s *Service) GetOrder(ctx context.Context, id string) (*Order, error) {
    return s.repo.FindByID(ctx, id)
}

func (r *Repo) FindByID(ctx context.Context, id string) (*Order, error) {
    return r.db.QueryRow(ctx, "SELECT ... WHERE id = $1", id).Scan(...)
}

Never store context in a struct. Never use context.Background() in a function that receives a context parameter. Always pass it as the first argument.

Timeout Chains

Layer timeouts from outer (generous) to inner (strict):

func (s *Service) ProcessOrder(ctx context.Context, order Order) error {
    // Overall operation: 30s
    ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
    defer cancel()

    // Validate inventory: 5s
    invCtx, invCancel := context.WithTimeout(ctx, 5*time.Second)
    defer invCancel()
    if err := s.inventory.Check(invCtx, order.Items); err != nil {
        return fmt.Errorf("inventory check: %w", err)
    }

    // Charge payment: 10s
    payCtx, payCancel := context.WithTimeout(ctx, 10*time.Second)
    defer payCancel()
    if err := s.payment.Charge(payCtx, order.Total); err != nil {
        return fmt.Errorf("payment: %w", err)
    }

    // Send confirmation: 5s
    notifCtx, notifCancel := context.WithTimeout(ctx, 5*time.Second)
    defer notifCancel()
    if err := s.notifier.Send(notifCtx, order); err != nil {
        // Non-critical — log and continue
        slog.Warn("notification failed", "order_id", order.ID, "error", err)
    }

    return nil
}

Each sub-operation has its own budget, but all are bounded by the 30s parent.

Cancellation Propagation

When a parent context cancels, all children cancel too. This is how you clean up complex operations:

func (s *Service) StreamUpdates(ctx context.Context, userID string) error {
    g, ctx := errgroup.WithContext(ctx)

    // If any goroutine fails, ctx cancels, and all others stop
    g.Go(func() error {
        return s.watchOrders(ctx, userID)
    })
    g.Go(func() error {
        return s.watchNotifications(ctx, userID)
    })
    g.Go(func() error {
        return s.watchMessages(ctx, userID)
    })

    return g.Wait()
}

If the HTTP connection drops, r.Context() cancels, which cancels the errgroup context, which stops all three watchers. No cleanup code needed — it’s built into the context chain.

Context Values: Use Sparingly

Context values are for request-scoped data that crosses API boundaries:

type contextKey string

const (
    correlationIDKey contextKey = "correlation_id"
    userIDKey        contextKey = "user_id"
)

// Middleware sets values
func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        userID := validateToken(r.Header.Get("Authorization"))
        ctx := context.WithValue(r.Context(), userIDKey, userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// Deep in the call stack, retrieve without parameter drilling
func (r *Repo) AuditLog(ctx context.Context, action string) {
    userID, _ := ctx.Value(userIDKey).(string)
    r.db.Exec(ctx, "INSERT INTO audit_log (user_id, action) VALUES ($1, $2)",
        userID, action)
}

Rules for context values:

  • Use custom types as keys (not strings) to avoid collisions
  • Only for request-scoped data (correlation IDs, auth info, tracing spans)
  • Never for optional function parameters
  • Never for data that should be in function signatures

Handling Cancellation Errors

Check context errors to distinguish timeouts from cancellations:

func (s *Service) DoWork(ctx context.Context) error {
    result, err := s.client.Call(ctx)
    if err != nil {
        if ctx.Err() == context.DeadlineExceeded {
            return fmt.Errorf("operation timed out: %w", err)
        }
        if ctx.Err() == context.Canceled {
            // Client disconnected — not really an error
            return nil
        }
        return err
    }
    return process(result)
}

Don’t log client cancellations as errors — they’re normal (user closed browser, mobile app went to background).

AfterFunc (Go 1.21+)

Register cleanup callbacks when context cancels:

func (s *Service) StartProcess(ctx context.Context) (*Process, error) {
    proc := startExpensiveProcess()

    stop := context.AfterFunc(ctx, func() {
        slog.Info("context cancelled, stopping process")
        proc.Stop()
    })

    // If we stop the process normally, cancel the AfterFunc
    // so it doesn't fire later
    go func() {
        proc.Wait()
        stop()
    }()

    return proc, nil
}

Context is Go’s answer to the cancellation problem in concurrent systems. Use it consistently, and your services will clean up gracefully when things go wrong.