gRPC Deadlines and Timeouts

A deadline is a fixed point in time after which a gRPC call must stop, whether or not it has finished. Deadlines prevent one slow operation from holding up an entire chain of services. They are one of the most important reliability patterns in distributed systems.

Deadline vs Timeout — The Difference

Timeout: a duration from now  (e.g., "give up after 5 seconds")
Deadline: a fixed timestamp    (e.g., "give up at 14:05:03.456 UTC")

gRPC uses deadlines internally. You usually set a timeout and the library
converts it to a deadline by adding the duration to the current time.

  now = 14:05:00.000
  timeout = 3 seconds
  deadline = 14:05:03.000

Why deadlines are better than timeouts for distributed systems:
  Service A calls Service B (2s timeout), B calls Service C (2s timeout)
  A's clock: 0ms → calls B
  B's clock: 1ms → calls C with a NEW 2s timeout

  Result: total possible wait = 2s (A waits for B) + 2s (B waits for C) = 4s
  With deadline propagation: C gets only the time A has remaining → prevents cascade

The Cascade Problem Without Deadlines

Without deadline propagation:
┌────────┐  request  ┌────────┐  request  ┌────────┐
│ Client │──────────►│  API   │──────────►│  DB    │
│        │           │ server │           │ server │  (stuck for 30s)
│        │           │        │           │        │
│        │  waits indefinitely           (hangs)   │
└────────┘           └────────┘           └────────┘

Result: the client waits forever; connections pile up; memory fills up

With deadline propagation:
┌────────┐  deadline: 14:05:03  ┌────────┐  deadline: 14:05:03  ┌───────┐
│ Client │─────────────────────►│  API   │─────────────────────►│  DB   │
│        │                      │ server │                      │ server│
│        │◄──DEADLINE_EXCEEDED ─┤        │◄──DEADLINE_EXCEEDED ─┤       │
└────────┘  at 14:05:03         └────────┘  at 14:05:03         └───────┘

All three stop at the same wall-clock time.

Setting a Deadline on the Client (Go)

import (
  "context"
  "time"
  "google.golang.org/grpc/codes"
  "google.golang.org/grpc/status"
)

func callWithDeadline(client pb.UserServiceClient, userId int32) (*pb.User, error) {
  // Option 1: Timeout (simpler — converts to deadline internally)
  ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
  defer cancel()   // always call cancel to release resources

  resp, err := client.GetUser(ctx, &pb.GetUserRequest{UserId: userId})
  if err != nil {
    st, _ := status.FromError(err)
    if st.Code() == codes.DeadlineExceeded {
      return nil, fmt.Errorf("GetUser timed out after 3 seconds")
    }
    return nil, err
  }
  return resp, nil
}

// Option 2: Absolute deadline
func callWithAbsoluteDeadline(client pb.UserServiceClient) {
  deadline := time.Now().Add(5 * time.Second)
  ctx, cancel := context.WithDeadline(context.Background(), deadline)
  defer cancel()

  resp, err := client.GetUser(ctx, &pb.GetUserRequest{UserId: 1})
  // ...
}

Checking the Deadline on the Server (Go)

func (s *server) GetUser(ctx context.Context,
    req *pb.GetUserRequest) (*pb.User, error) {

  // Check if caller already cancelled or deadline already passed
  if ctx.Err() != nil {
    return nil, status.FromContextError(ctx.Err()).Err()
  }

  // Check how much time is left before starting expensive work
  deadline, ok := ctx.Deadline()
  if ok {
    remaining := time.Until(deadline)
    if remaining < 50*time.Millisecond {
      // Not enough time to do useful work
      return nil, status.Error(codes.DeadlineExceeded,
        "insufficient time remaining")
    }
    log.Printf("Time remaining for request: %v", remaining)
  }

  // Do the actual work
  user, err := s.db.FetchUser(ctx, req.UserId)  // pass ctx to DB call
  if err != nil {
    return nil, err
  }
  return user, nil
}

Propagating Deadlines Through a Chain

The key rule: always pass the incoming ctx to every downstream call. Never create a fresh context.Background() for a downstream call — you would discard the original deadline.

Service A → Service B → Service C

// CORRECT — deadline propagates through the chain
func (s *OrderService) CreateOrder(ctx context.Context, req *pb.CreateOrderRequest) (*pb.Order, error) {
  // ctx contains the deadline from the original caller

  // Pass ctx to inventory check — inherits remaining deadline
  inv, err := s.inventoryClient.Reserve(ctx, &inv.ReserveRequest{...})
  if err != nil { return nil, err }

  // Pass ctx to payment — inherits remaining deadline
  pay, err := s.paymentClient.Charge(ctx, &pay.ChargeRequest{...})
  if err != nil { return nil, err }

  return &pb.Order{...}, nil
}

// WRONG — creates a new context, ignores original deadline
func (s *OrderService) CreateOrder(ctx context.Context, req *pb.CreateOrderRequest) (*pb.Order, error) {
  freshCtx := context.Background()  // ← discard! new context has no deadline

  inv, err := s.inventoryClient.Reserve(freshCtx, ...)  // runs forever
  // ...
}

How gRPC Sends Deadlines on the Wire

gRPC encodes the deadline as a relative timeout in the request headers:

  grpc-timeout: 3000m    (3000 milliseconds = 3 seconds)
  grpc-timeout: 5S       (5 seconds)
  grpc-timeout: 500000u  (500000 microseconds = 500ms)

The receiving server converts this relative timeout back into an absolute
deadline using its own clock at the moment the request arrives.

Units:
  H = hours
  M = minutes
  S = seconds
  m = milliseconds
  u = microseconds
  n = nanoseconds

Context Cancellation

A client can cancel a call at any time, not just when the deadline expires. Cancellation signals the server to stop work early and release resources.

Client cancels manually:
  ctx, cancel := context.WithCancel(context.Background())

  // Start the call in a goroutine
  go func() {
    resp, err := client.LongRunningExport(ctx, req)
    // err will be codes.Canceled if cancel() was called
  }()

  // User pressed "Cancel" button after 2 seconds
  time.Sleep(2 * time.Second)
  cancel()   // signals the server to stop

Server detects cancellation:
  func (s *server) LongRunningExport(ctx context.Context,
      req *pb.ExportRequest) (*pb.ExportResult, error) {

    for i, row := range s.fetchAllRows() {
      // Check context every few iterations
      if i%100 == 0 {
        select {
        case <-ctx.Done():
          return nil, status.Error(codes.Canceled,
            "export cancelled by client")
        default:
        }
      }
      processRow(row)
    }
    return &pb.ExportResult{...}, nil
  }

Recommended Deadline Values

┌────────────────────────────────┬───────────────────────────────┐
│ Operation Type                 │ Suggested Deadline            │
├────────────────────────────────┼───────────────────────────────┤
│ Fast DB lookup (by primary key)│ 500ms – 1s                    │
│ Auth token validation          │ 500ms                         │
│ Complex search / query         │ 2s – 5s                       │
│ Payment processing             │ 10s – 15s                     │
│ File upload / export           │ 30s – 120s                    │
│ Background batch job           │ No deadline (use task queue)  │
└────────────────────────────────┴───────────────────────────────┘

Summary

Deadlines set a fixed expiry time on a gRPC call. Always set a deadline on outgoing calls — calls without deadlines can block forever and cascade failures through your system. Propagate the incoming context to every downstream call so the deadline is respected end to end. Use ctx.Err() on the server to detect when a client has cancelled or when the deadline has passed before doing expensive work.

Leave a Comment

Your email address will not be published. Required fields are marked *