Back to blog

gRPC Deadline Propagation: Preventing Cascading Failures

|

We set deadlines at the edge, but forgot they need to travel with the request. “Why is backend CPU at 100% when frontend shows ‘timeout’?” We discovered this during a capacity planning exercise. The backend services were running hot, CPU at 100%, but the frontend was reporting high timeout rates. It didn’t make sense—if requests were timing out, why was the backend so busy?

The answer was that every timed-out frontend request was still being processed by the backend. The frontend gave up after 5 seconds, returned an error to the user, but the backend kept working for another 25 seconds on a request that nobody was waiting for. Multiply this by thousands of concurrent requests, and you have a backend doing massive amounts of useless work.

This is what deadline propagation solves. When the frontend sets a 5-second timeout, that deadline should flow through every service in the call chain. When the deadline expires, every service should stop working immediately. The user already got an error—there’s no point continuing.

The concept seems obvious once you understand it, but I’ve seen many systems where it’s not implemented. Each service has its own timeout configured independently, and they don’t coordinate. The frontend times out quickly (for user experience), but the backend has long timeouts (for “reliability”). The mismatch creates zombie requests that consume resources long after anyone cares about the result.

Tested on: Go 1.21, gRPC 1.58, 3-tier microservices architecture

The Problem

Without Deadline Propagation

Timeline of a request without deadline propagation:

Frontend (5s timeout)        Backend A (30s timeout)       Backend B (30s timeout)
        │                           │                            │
   0s   │─── Request ───────────────▶                            │
        │                           │─── Request ─────────────────▶
        │                           │                            │
   5s   │ TIMEOUT! Respond 504      │                            │
        │ (client gave up)          │                            │
        │                           │                            │ Processing...
  15s   │                           │                            │
        │                           │                            │
  25s   │                           │                            │ Done!
        │                           │◀── Response ────────────────│
        │                           │                            │
  30s   │                           │ Done!                      │
        │                           │ (response thrown away)     │

Result: 25 seconds of wasted work on 2 backends

With Deadline Propagation

Timeline with deadline propagation:

Frontend (5s timeout)        Backend A                      Backend B
        │                           │                            │
   0s   │─── Request ───────────────▶                            │
        │   (deadline: 5s)          │─── Request ─────────────────▶
        │                           │   (deadline: 4.9s)         │
        │                           │                            │
   5s   │ TIMEOUT!                  │ Context cancelled!         │ Context cancelled!
        │ Respond 504               │ Stop work immediately      │ Stop work immediately
        │                           │                            │

Result: Work stopped immediately when frontend gives up

Implementation

Server-Side: Respecting Context

// service.go
func (s *Server) ProcessOrder(ctx context.Context, req *pb.OrderRequest) (*pb.OrderResponse, error) {
    // Check context before expensive operations
    if ctx.Err() != nil {
        return nil, status.FromContextError(ctx.Err()).Err()
    }

    // Check periodically during long operations
    for _, item := range req.Items {
        select {
        case <-ctx.Done():
            // Client gave up, stop processing
            log.Info("Context cancelled, aborting order processing")
            return nil, status.FromContextError(ctx.Err()).Err()
        default:
        }

        if err := s.processItem(ctx, item); err != nil {
            return nil, err
        }
    }

    return &pb.OrderResponse{OrderId: "123"}, nil
}

// Pass context to downstream services
func (s *Server) processItem(ctx context.Context, item *pb.Item) error {
    // Context automatically carries deadline to downstream call
    resp, err := s.inventoryClient.CheckStock(ctx, &pb.StockRequest{
        ItemId: item.Id,
    })
    if err != nil {
        return err
    }
    // ...
}

Client-Side: Setting Deadlines

// client.go
func (c *OrderClient) CreateOrder(items []Item) (*Order, error) {
    // Set deadline for entire operation
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    resp, err := c.grpcClient.ProcessOrder(ctx, &pb.OrderRequest{
        Items: toPBItems(items),
    })

    if err != nil {
        // Check if it was a timeout
        if status.Code(err) == codes.DeadlineExceeded {
            return nil, fmt.Errorf("order processing timed out")
        }
        return nil, err
    }

    return fromPBOrder(resp), nil
}

Interceptor for Automatic Propagation

// interceptors.go

// UnaryClientInterceptor propagates deadline via metadata
func DeadlinePropagationInterceptor() grpc.UnaryClientInterceptor {
    return func(
        ctx context.Context,
        method string,
        req, reply interface{},
        cc *grpc.ClientConn,
        invoker grpc.UnaryInvoker,
        opts ...grpc.CallOption,
    ) error {
        // gRPC automatically propagates deadline in context
        // This interceptor adds logging/metrics

        deadline, ok := ctx.Deadline()
        if ok {
            remaining := time.Until(deadline)
            log.Debug("Calling %s with deadline in %v", method, remaining)

            // Optionally add buffer for network latency
            if remaining < 100*time.Millisecond {
                return status.Error(codes.DeadlineExceeded,
                    "insufficient time remaining for RPC")
            }
        }

        return invoker(ctx, method, req, reply, cc, opts...)
    }
}

// UnaryServerInterceptor logs incoming deadlines
func DeadlineLoggingInterceptor() grpc.UnaryServerInterceptor {
    return func(
        ctx context.Context,
        req interface{},
        info *grpc.UnaryServerInfo,
        handler grpc.UnaryHandler,
    ) (interface{}, error) {
        deadline, ok := ctx.Deadline()
        if ok {
            remaining := time.Until(deadline)
            log.Debug("Received %s with deadline in %v", info.FullMethod, remaining)

            // Add to metrics
            rpcDeadlineRemaining.WithLabelValues(info.FullMethod).Observe(remaining.Seconds())
        } else {
            log.Warn("Received %s without deadline", info.FullMethod)
        }

        return handler(ctx, req)
    }
}

Handling Streaming RPCs

// For streaming, check context between messages
func (s *Server) StreamOrders(req *pb.StreamRequest, stream pb.OrderService_StreamOrdersServer) error {
    ctx := stream.Context()

    for {
        select {
        case <-ctx.Done():
            return status.FromContextError(ctx.Err()).Err()
        case order := <-s.orderChannel:
            if err := stream.Send(order); err != nil {
                return err
            }
        }
    }
}

Database and External Calls

Propagating to Database

// Pass context to database queries
func (r *Repository) GetOrder(ctx context.Context, id string) (*Order, error) {
    // Context propagates to database driver
    row := r.db.QueryRowContext(ctx,
        "SELECT id, status FROM orders WHERE id = $1", id)

    var order Order
    if err := row.Scan(&order.ID, &order.Status); err != nil {
        // Will return error if context cancelled
        return nil, err
    }
    return &order, nil
}

Propagating to HTTP Calls

// Make HTTP request with context deadline
func (c *ExternalClient) CallAPI(ctx context.Context, data []byte) ([]byte, error) {
    req, err := http.NewRequestWithContext(ctx, "POST", c.url, bytes.NewReader(data))
    if err != nil {
        return nil, err
    }

    resp, err := c.httpClient.Do(req)
    if err != nil {
        // Context cancellation returns here
        return nil, err
    }
    defer resp.Body.Close()

    return io.ReadAll(resp.Body)
}

Propagating to Redis

// go-redis respects context
func (c *Cache) Get(ctx context.Context, key string) (string, error) {
    return c.rdb.Get(ctx, key).Result()
}

func (c *Cache) Set(ctx context.Context, key, value string, ttl time.Duration) error {
    return c.rdb.Set(ctx, key, value, ttl).Err()
}

Deadline Budgeting

Reserving Time for Response

// Reserve time for serialization and network
func WithResponseBudget(ctx context.Context, budget time.Duration) (context.Context, context.CancelFunc) {
    deadline, ok := ctx.Deadline()
    if !ok {
        return ctx, func() {}
    }

    // New deadline = original - budget
    newDeadline := deadline.Add(-budget)
    if time.Now().After(newDeadline) {
        // Already exceeded budget
        ctx, cancel := context.WithCancel(ctx)
        cancel() // Immediately cancelled
        return ctx, cancel
    }

    return context.WithDeadline(ctx, newDeadline)
}

// Usage
func (s *Server) ProcessOrder(ctx context.Context, req *pb.OrderRequest) (*pb.OrderResponse, error) {
    // Reserve 100ms for response
    ctx, cancel := WithResponseBudget(ctx, 100*time.Millisecond)
    defer cancel()

    // Now processing has 100ms less time
    result, err := s.doProcessing(ctx, req)
    // ...
}

Per-Operation Budgets

// Divide deadline between operations
func (s *Server) ComplexOperation(ctx context.Context, req *Request) (*Response, error) {
    deadline, ok := ctx.Deadline()
    if !ok {
        // No deadline, use default
        var cancel context.CancelFunc
        ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
        defer cancel()
        deadline = time.Now().Add(30 * time.Second)
    }

    total := time.Until(deadline)

    // Phase 1: Validation (10% of budget)
    phase1Ctx, cancel1 := context.WithTimeout(ctx, total/10)
    defer cancel1()
    if err := s.validate(phase1Ctx, req); err != nil {
        return nil, err
    }

    // Phase 2: Processing (70% of budget)
    phase2Ctx, cancel2 := context.WithTimeout(ctx, total*7/10)
    defer cancel2()
    result, err := s.process(phase2Ctx, req)
    if err != nil {
        return nil, err
    }

    // Phase 3: Persist (remaining 20%)
    if err := s.persist(ctx, result); err != nil {
        return nil, err
    }

    return result, nil
}

Monitoring

Metrics

var (
    rpcDeadlineRemaining = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "grpc_deadline_remaining_seconds",
            Buckets: []float64{0.01, 0.1, 0.5, 1, 2, 5, 10, 30},
        },
        []string{"method"},
    )

    rpcDeadlineExceeded = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "grpc_deadline_exceeded_total",
        },
        []string{"method"},
    )

    rpcNoDeadline = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "grpc_no_deadline_total",
        },
        []string{"method"},
    )
)

Alerts

groups:
- name: grpc_deadlines
  rules:
  - alert: HighDeadlineExceededRate
    expr: |
      rate(grpc_deadline_exceeded_total[5m]) /
      rate(grpc_server_handled_total[5m]) > 0.1
    for: 5m
    annotations:
      summary: ">10% of requests exceeding deadline"

  - alert: TightDeadlines
    expr: |
      histogram_quantile(0.5, rate(grpc_deadline_remaining_seconds_bucket[5m])) < 0.5
    for: 10m
    annotations:
      summary: "Median incoming deadline <500ms"

  - alert: MissingDeadlines
    expr: |
      rate(grpc_no_deadline_total[5m]) > 0
    for: 5m
    annotations:
      summary: "Requests arriving without deadlines"

Checklist

## gRPC Deadline Propagation

### Client-Side
- [ ] Always set context timeout/deadline
- [ ] Use context.WithTimeout for top-level calls
- [ ] Handle DeadlineExceeded errors appropriately

### Server-Side
- [ ] Check ctx.Done() before expensive operations
- [ ] Pass context to all downstream calls
- [ ] Use QueryContext/ExecContext for database
- [ ] Use NewRequestWithContext for HTTP

### Interceptors
- [ ] Log incoming deadlines
- [ ] Metric on deadline remaining
- [ ] Reject calls with insufficient time remaining

### Monitoring
- [ ] Track deadline exceeded rate
- [ ] Alert on tight deadlines
- [ ] Dashboard showing deadline distribution

Conclusion

Deadline propagation is one of those patterns that seems like extra work until you see the impact of not having it. In a 3-tier architecture with 1,000 requests per second and a 10% timeout rate, you’re wasting 100 backend requests every second—each potentially consuming 25 seconds of work. That’s 2,500 request-seconds of wasted computation every second. The backend is doing work that makes the system slower, not faster.

The beauty of gRPC is that deadline propagation is built into the protocol. The context carries the deadline automatically. You just need to use it correctly: pass the context to every downstream call, check ctx.Done() during long operations, and configure your interceptors to log and monitor deadline behavior.

The key insight is that in distributed systems, a timeout isn’t just a failure—it’s a signal that no one is waiting for the result anymore. Respecting that signal by stopping work immediately is the difference between a system that degrades gracefully under load and one that spirals into complete resource exhaustion.

Key principles:

  1. Set deadlines on all RPC calls from clients - every external call should have a timeout
  2. Pass context to all downstream calls in servers - context carries the deadline
  3. Check ctx.Done() during long operations - stop work when deadline expires
  4. Monitor deadline metrics to catch issues - track how much time remains when requests arrive

Stop processing requests nobody is waiting for. Your backend will thank you.


Related posts

Cite this article

If you reference this post, please link to the original URL and credit the author.

Michal Drozd. "gRPC Deadline Propagation: Preventing Cascading Failures". https://www.michal-drozd.com/en/blog/grpc-deadline-propagation/ (Published August 23, 2025).