The hanging request problem
You deploy a gRPC service, wire it to a database, and run load tests. The first hundred requests finish in milliseconds. Then the database connection pool saturates. One query takes four seconds. Then eight. Your client hangs. The thread pool fills up. The entire pipeline grinds to a halt because a single call decided to wait indefinitely. Go solves this with deadlines and timeouts, baked directly into the context package. gRPC speaks context natively, so you get deadline propagation across network boundaries without writing extra plumbing.
Timeout versus deadline
A timeout measures duration from the moment you create it. A deadline measures an absolute point in time. Both produce a context.Context that carries a cancellation signal. When the limit passes, the context fires. Any code holding that context can check ctx.Err() and see context.DeadlineExceeded or context.Canceled. gRPC intercepts this signal automatically. On the client side, it aborts the outgoing request. On the server side, it tells the handler to stop working and return early.
Think of a deadline like a train departure time. You can calculate how long you have left, but the clock keeps ticking regardless of when you arrive at the station. A timeout is like a timer on a microwave. It starts counting down the second you press start. In distributed systems, deadlines usually win because they prevent timeout stacking. If you chain three services and each adds a five-second timeout, the total wait could be fifteen seconds. A single deadline propagates through the chain, and every layer knows exactly when the work must finish.
Contexts are cheap to create but expensive to leak. Always pair context.WithTimeout or context.WithDeadline with a cancel function, and always defer it. The context package uses a background timer and a channel. If you forget to call cancel, the timer runs until it expires, which wastes CPU cycles and keeps heap allocations alive longer than necessary.
Deadlines travel. Timeouts stay local.
The client pattern
Here is the standard client setup. Create a context with a duration limit, attach it to the gRPC call, and clean up the resources immediately.
package main
import (
"context"
"fmt"
"log"
"time"
)
// CallService demonstrates a basic gRPC client call with a timeout.
func CallService(client MyServiceClient) {
// WithTimeout starts a countdown from the current moment.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// cancel releases the context's internal timer and prevents resource leaks.
defer cancel()
// The gRPC client automatically attaches the context deadline to the RPC.
resp, err := client.GetData(ctx, &GetDataRequest{})
if err != nil {
// gRPC returns a status error when the context expires.
log.Printf("request failed: %v", err)
return
}
fmt.Println("received:", resp)
}
The context.Background() call creates an empty root context. It carries no values and no deadline. You only use it at the top level of your application, like in main() or test functions. Wrapping it with WithTimeout creates a child context that inherits the parent and adds a timer. The returned cancel function stops the timer and closes the internal notification channel.
What happens under the hood
When context.WithTimeout runs, the Go runtime allocates a time.Timer and a done channel. The timer fires after the specified duration and sends a signal down the channel. The context object holds a reference to both. When you pass ctx to client.GetData, the gRPC library extracts the deadline and converts it into an HTTP/2 header called grpc-timeout. This header tells the server exactly how many milliseconds the client expects the response.
If the server responds before the limit, the stream completes normally. If the server is slow, the client-side timer fires. gRPC tears down the HTTP/2 stream, returns an error with codes.DeadlineExceeded, and your error handling block runs. The context cancellation propagates back to the server over the same connection. The server handler receives the same context object, so it can check ctx.Err() and stop processing mid-flight.
You can verify the deadline at any point by calling ctx.Deadline(). It returns a time.Time and a boolean. If the boolean is false, no deadline was set. If you try to pass a non-context value where a context is expected, the compiler rejects the program with cannot use x (variable of type string) as context.Context value in argument. Go's type system enforces the boundary strictly.
Contexts are plumbing. Run them through every long-lived call site.
Server-side deadline awareness
Server handlers receive the client's context as the first parameter. The gRPC framework sets up the signature for you. You do not create the context on the server. You inherit it. Here is a realistic handler that processes a batch of records and checks for cancellation between iterations.
package main
import (
"context"
"fmt"
"log"
)
// ProcessBatch handles a long-running gRPC request while respecting client deadlines.
func ProcessBatch(ctx context.Context, req *BatchRequest) (*BatchResponse, error) {
// Check the deadline upfront to fail fast if the client already timed out.
deadline, ok := ctx.Deadline()
if !ok {
log.Println("no deadline set, proceeding with default behavior")
} else {
log.Printf("server deadline: %v", deadline)
}
var results []string
for i, item := range req.Items {
// Check cancellation before each heavy operation to avoid wasted work.
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("context canceled during batch: %w", err)
}
// Simulate work that respects the context.
result, err := processItem(ctx, item)
if err != nil {
return nil, err
}
results = append(results, result)
log.Printf("processed item %d", i)
}
return &BatchResponse{Results: results}, nil
}
The context.Context parameter always goes first in Go functions, conventionally named ctx. Functions that accept a context should respect cancellation and deadlines. This is a hard convention in the standard library and most third-party packages. If you see a function signature like func DoWork(ctx context.Context, id int) error, you can safely assume it will check ctx.Err() and stop early if the context fires. Database drivers, HTTP clients, and gRPC stubs all follow this pattern.
Long-running loops must check ctx.Err() explicitly. The context does not interrupt your code automatically. It only provides a channel you can select on, or an error you can read. If you skip the check, your handler will finish the entire batch even after the client gave up. That wastes CPU, fills logs, and delays garbage collection.
Respect the signal. Return early.
Common traps and runtime failures
The most common mistake is treating the context as optional. If you pass context.Background() to a gRPC call, the request will wait until the underlying transport gives up, which can take minutes. The compiler will not stop you. Go treats context as just another parameter. You can technically pass nil, but gRPC will panic with rpc error: code = Internal desc = grpc: the client connection is closing or a similar transport error depending on the version. Always pass a valid context.
Another trap is ignoring the error type. When a deadline passes, gRPC returns a *status.Error with code DeadlineExceeded. You can check it with status.Code(err) == codes.DeadlineExceeded. If you just log the error string, you lose the structured information. The Go community convention is to wrap errors with fmt.Errorf("...: %w", err) so callers can unwrap and inspect the status code later. The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible.
Goroutine leaks happen when you spawn a background worker inside a handler but forget to tie it to the request context. If the client cancels the request, the handler returns, but the background goroutine keeps running until it finishes or panics. Always derive child contexts with context.WithoutCancel or pass the request context to every spawned goroutine. The worst goroutine bug is the one that never logs.
Testing timeouts requires patience. You cannot mock time easily in Go without using a test helper or a custom clock. The standard approach is to write a test that starts a slow server, calls it with a short timeout, and asserts that the error contains context.DeadlineExceeded. Keep the timeout under two seconds in tests to avoid CI delays.
Choosing the right context helper
Use context.WithTimeout when you know the maximum duration a single operation should take, like waiting for a database query or an external API. Use context.WithDeadline when you need to coordinate multiple steps under a single absolute time limit, or when you are chaining services and want to prevent timeout stacking. Use context.WithCancel when you need manual control over cancellation, such as stopping a worker when a user closes their browser tab. Use context.Background() only at the top level of your application, like in main() or test functions, where no external deadline exists.