When Simple Context Isn’t Enough

By now you’ve got the basics down, but real-world systems throw curveballs that basic context patterns can’t handle. What do you do when you need different parts of an operation to have different timeout behaviors? How do you coordinate partial failures across multiple services? These are the problems that advanced context patterns solve.

I’ve spent years dealing with these edge cases, and I’ve learned that the most elegant solutions often involve combining multiple context concepts in creative ways.

Context Multiplexing (Different Rules for Different Operations)

Sometimes you need to run operations in parallel, but each one needs different cancellation and timeout rules:

type ContextMultiplexer struct {
    parent   context.Context
    children map[string]contextInfo
    mu       sync.RWMutex
}

type contextInfo struct {
    ctx    context.Context
    cancel context.CancelFunc
}

func NewContextMultiplexer(parent context.Context) *ContextMultiplexer {
    return &ContextMultiplexer{
        parent:   parent,
        children: make(map[string]contextInfo),
    }
}

func (cm *ContextMultiplexer) CreateChild(name string, timeout time.Duration) context.Context {
    cm.mu.Lock()
    defer cm.mu.Unlock()
    
    ctx, cancel := context.WithTimeout(cm.parent, timeout)
    cm.children[name] = contextInfo{ctx: ctx, cancel: cancel}
    
    return ctx
}

func (cm *ContextMultiplexer) CancelChild(name string) {
    cm.mu.Lock()
    defer cm.mu.Unlock()
    
    if info, exists := cm.children[name]; exists {
        info.cancel()
        delete(cm.children, name)
    }
}

func (cm *ContextMultiplexer) CancelAll() {
    cm.mu.Lock()
    defer cm.mu.Unlock()
    
    for _, info := range cm.children {
        info.cancel()
    }
    cm.children = make(map[string]contextInfo)
}

// Real-world usage: fetching user data from multiple sources
func fetchCompleteUserProfile(ctx context.Context, userID string) (*UserProfile, error) {
    mux := NewContextMultiplexer(ctx)
    defer mux.CancelAll()
    
    // Different timeouts for different data sources
    profileCtx := mux.CreateChild("profile", 2*time.Second)    // Fast
    prefsCtx := mux.CreateChild("preferences", 5*time.Second)  // Medium
    historyCtx := mux.CreateChild("history", 10*time.Second)   // Slow
    
    type fetchResult struct {
        name string
        data interface{}
        err  error
    }
    
    results := make(chan fetchResult, 3)
    
    go func() {
        data, err := fetchBasicProfile(profileCtx, userID)
        results <- fetchResult{"profile", data, err}
    }()
    
    go func() {
        data, err := fetchUserPreferences(prefsCtx, userID)
        results <- fetchResult{"preferences", data, err}
    }()
    
    go func() {
        data, err := fetchUserHistory(historyCtx, userID)
        results <- fetchResult{"history", data, err}
    }()
    
    profile := &UserProfile{}
    for i := 0; i < 3; i++ {
        select {
        case result := <-results:
            if result.err != nil {
                // Cancel everything on any error
                mux.CancelAll()
                return nil, fmt.Errorf("%s fetch failed: %w", result.name, result.err)
            }
            // Populate profile based on result type...
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
    
    return profile, nil
}

This pattern gives you fine-grained control over each operation while maintaining overall coordination.

Dynamic Context Adaptation

Sometimes you need context behavior to change based on runtime conditions. Here’s how I handle that:

type AdaptiveContext struct {
    base      context.Context
    modifiers []ContextModifier
}

type ContextModifier interface {
    ShouldApply(ctx context.Context) bool
    Apply(ctx context.Context) (context.Context, context.CancelFunc)
}

// Example: Extend timeout for premium users
type PremiumUserModifier struct {
    extraTime time.Duration
}

func (pum *PremiumUserModifier) ShouldApply(ctx context.Context) bool {
    userID, ok := GetUserID(ctx)
    return ok && isPremiumUser(userID)
}

func (pum *PremiumUserModifier) Apply(ctx context.Context) (context.Context, context.CancelFunc) {
    return context.WithTimeout(ctx, pum.extraTime)
}

// Example: Reduce timeout under high load
type LoadBasedModifier struct {
    reducedTimeout time.Duration
}

func (lbm *LoadBasedModifier) ShouldApply(ctx context.Context) bool {
    return getCurrentSystemLoad() > 0.8
}

func (lbm *LoadBasedModifier) Apply(ctx context.Context) (context.Context, context.CancelFunc) {
    return context.WithTimeout(ctx, lbm.reducedTimeout)
}

func NewAdaptiveContext(base context.Context) *AdaptiveContext {
    return &AdaptiveContext{
        base:      base,
        modifiers: make([]ContextModifier, 0),
    }
}

func (ac *AdaptiveContext) AddModifier(modifier ContextModifier) {
    ac.modifiers = append(ac.modifiers, modifier)
}

func (ac *AdaptiveContext) CreateContext() (context.Context, context.CancelFunc) {
    ctx := ac.base
    var cancels []context.CancelFunc
    
    for _, modifier := range ac.modifiers {
        if modifier.ShouldApply(ctx) {
            var cancel context.CancelFunc
            ctx, cancel = modifier.Apply(ctx)
            if cancel != nil {
                cancels = append(cancels, cancel)
            }
        }
    }
    
    // Return combined cancel function
    return ctx, func() {
        for _, cancel := range cancels {
            cancel()
        }
    }
}

This lets your context behavior adapt to user types, system load, or any other runtime conditions.

Context Pipelines (Chaining Operations with Context Evolution)

In complex processing pipelines, each stage might need to modify the context for subsequent stages:

type PipelineStage interface {
    Process(ctx context.Context, data interface{}) (context.Context, interface{}, error)
    Name() string
}

type ValidationStage struct{}

func (vs *ValidationStage) Name() string { return "validation" }

func (vs *ValidationStage) Process(ctx context.Context, data interface{}) (context.Context, interface{}, error) {
    req := data.(*ProcessingRequest)
    
    // High priority requests get extended timeouts
    if req.Priority == "high" {
        ctx = context.WithValue(ctx, "priority", "high")
        // Extend timeout for high priority
        newCtx, _ := context.WithTimeout(ctx, 60*time.Second)
        ctx = newCtx
    }
    
    if err := validateRequest(req); err != nil {
        return ctx, nil, err
    }
    
    return ctx, req, nil
}

type ProcessingStage struct{}

func (ps *ProcessingStage) Name() string { return "processing" }

func (ps *ProcessingStage) Process(ctx context.Context, data interface{}) (context.Context, interface{}, error) {
    req := data.(*ProcessingRequest)
    
    // Check if previous stage marked this as high priority
    if priority := ctx.Value("priority"); priority == "high" {
        result, err := processHighPriority(ctx, req)
        return ctx, result, err
    }
    
    result, err := processNormal(ctx, req)
    return ctx, result, err
}

type ContextPipeline struct {
    stages []PipelineStage
}

func NewContextPipeline(stages ...PipelineStage) *ContextPipeline {
    return &ContextPipeline{stages: stages}
}

func (cp *ContextPipeline) Execute(ctx context.Context, initialData interface{}) (interface{}, error) {
    currentCtx := ctx
    currentData := initialData
    
    for _, stage := range cp.stages {
        var err error
        currentCtx, currentData, err = stage.Process(currentCtx, currentData)
        if err != nil {
            return nil, fmt.Errorf("stage %s failed: %w", stage.Name(), err)
        }
        
        // Check for cancellation between stages
        select {
        case <-currentCtx.Done():
            return nil, currentCtx.Err()
        default:
        }
    }
    
    return currentData, nil
}

This pipeline pattern allows each stage to influence how subsequent stages behave through context modification.

Context Resource Pooling

For expensive resources that need context-aware lifecycle management:

type ContextAwarePool struct {
    pool     sync.Pool
    active   map[interface{}]context.CancelFunc
    mu       sync.RWMutex
    maxAge   time.Duration
}

func NewContextAwarePool(factory func() interface{}, maxAge time.Duration) *ContextAwarePool {
    return &ContextAwarePool{
        pool: sync.Pool{New: factory},
        active: make(map[interface{}]context.CancelFunc),
        maxAge: maxAge,
    }
}

func (cap *ContextAwarePool) Get(ctx context.Context) (interface{}, error) {
    resource := cap.pool.Get()
    
    // Create context for this resource with max age
    resourceCtx, cancel := context.WithTimeout(ctx, cap.maxAge)
    
    cap.mu.Lock()
    cap.active[resource] = cancel
    cap.mu.Unlock()
    
    // Monitor for context cancellation
    go func() {
        <-resourceCtx.Done()
        cap.forceReturn(resource)
    }()
    
    return resource, nil
}

func (cap *ContextAwarePool) Put(resource interface{}) {
    cap.mu.Lock()
    if cancel, exists := cap.active[resource]; exists {
        cancel()
        delete(cap.active, resource)
    }
    cap.mu.Unlock()
    
    cap.pool.Put(resource)
}

func (cap *ContextAwarePool) forceReturn(resource interface{}) {
    cap.mu.Lock()
    if cancel, exists := cap.active[resource]; exists {
        cancel()
        delete(cap.active, resource)
    }
    cap.mu.Unlock()
    
    // Clean up the resource if needed
    if closer, ok := resource.(io.Closer); ok {
        closer.Close()
    }
}

This pool automatically manages resource lifecycles based on context cancellation and age limits.

Context Merging (Combining Multiple Contexts)

When you need to combine contexts from different sources while preserving all their capabilities:

type MergedContext struct {
    contexts []context.Context
    done     chan struct{}
    err      error
    once     sync.Once
}

func MergeContexts(contexts ...context.Context) *MergedContext {
    mc := &MergedContext{
        contexts: contexts,
        done:     make(chan struct{}),
    }
    
    go mc.monitor()
    return mc
}

func (mc *MergedContext) monitor() {
    // Use reflection to wait on multiple channels
    cases := make([]reflect.SelectCase, len(mc.contexts))
    for i, ctx := range mc.contexts {
        cases[i] = reflect.SelectCase{
            Dir:  reflect.SelectRecv,
            Chan: reflect.ValueOf(ctx.Done()),
        }
    }
    
    chosen, _, _ := reflect.Select(cases)
    
    mc.once.Do(func() {
        mc.err = mc.contexts[chosen].Err()
        close(mc.done)
    })
}

func (mc *MergedContext) Done() <-chan struct{} {
    return mc.done
}

func (mc *MergedContext) Err() error {
    return mc.err
}

func (mc *MergedContext) Deadline() (time.Time, bool) {
    var earliest time.Time
    hasDeadline := false
    
    for _, ctx := range mc.contexts {
        if deadline, ok := ctx.Deadline(); ok {
            if !hasDeadline || deadline.Before(earliest) {
                earliest = deadline
                hasDeadline = true
            }
        }
    }
    
    return earliest, hasDeadline
}

func (mc *MergedContext) Value(key interface{}) interface{} {
    for _, ctx := range mc.contexts {
        if value := ctx.Value(key); value != nil {
            return value
        }
    }
    return nil
}

This merged context cancels when any of its constituent contexts cancel, and uses the earliest deadline.

These advanced patterns become essential when you’re building complex distributed systems where simple request-response patterns aren’t enough. They give you the tools to coordinate sophisticated operations while maintaining the benefits of context-based lifecycle management.

Next, we’ll dive into error handling and recovery patterns that work with these advanced context scenarios.