gRPC Service Design: Patterns, Streaming, and Best Practices
How do I design gRPC services?
TL;DR
- Bottom line: Design gRPC services protobuf-first with strongly-typed contracts, use streaming for real-time data flows, and enforce deadlines on every RPC to prevent cascading failures.
- Key tool/command:
protoc --go_out=. --go-grpc_out=. service.proto(or language-specific plugin) - Watch out for: Breaking backward compatibility by reusing or removing proto field numbers — always use
reservedto retire fields. - Works with: Any HTTP/2 environment. Go, Java, Python, Node.js, C++, Rust, C# all have official gRPC libraries. Requires gRPC-Web or Envoy transcoding for browser clients.
Constraints
- MUST use proto3 syntax for new services — proto2 lacks first-class support for optional fields and has legacy baggage
- NEVER reuse or reassign proto field numbers after deletion — use
reservedto prevent accidental reuse - NEVER remove or rename fields in production protos — deprecate with
[deprecated=true]and reserve the field number - Always set deadlines on RPCs — unbounded RPCs accumulate server resources and can cascade failures
- gRPC requires HTTP/2 — browsers cannot call gRPC directly without gRPC-Web or a transcoding proxy
- Default max message size is 4 MB — override explicitly for large payloads or prefer streaming
Quick Reference
| RPC Type | Proto Syntax | Use Case | Complexity | Error Handling | Flow Control |
|---|---|---|---|---|---|
| Unary | rpc Get(Req) returns (Resp) | CRUD, lookups, commands | Low | Single status code + details | N/A |
| Server streaming | rpc List(Req) returns (stream Resp) | Feed updates, large result sets, log tailing | Medium | Status on stream close; per-message errors via oneof | Backpressure built-in |
| Client streaming | rpc Upload(stream Req) returns (Resp) | File upload, batch ingest, telemetry | Medium | Server responds after all messages; early cancel possible | Client controls send rate |
| Bidirectional streaming | rpc Chat(stream Req) returns (stream Resp) | Real-time chat, multiplayer sync, live dashboards | High | Both sides can error independently; need app-level acks | Independent read/write streams |
gRPC Status Codes Quick Lookup
| Code | Constant | When to Use |
|---|---|---|
| 0 | OK | Success |
| 3 | INVALID_ARGUMENT | Client sent malformed request |
| 5 | NOT_FOUND | Requested resource does not exist |
| 7 | PERMISSION_DENIED | Caller lacks permission (authenticated but unauthorized) |
| 8 | RESOURCE_EXHAUSTED | Rate limit hit or quota exceeded |
| 13 | INTERNAL | Unexpected server error (do not expose details to client) |
| 14 | UNAVAILABLE | Transient failure — client should retry with backoff |
| 16 | UNAUTHENTICATED | Missing or invalid auth credentials |
Decision Tree
START: What kind of API communication do you need?
+-- Browser clients calling your API directly?
| +-- YES -> Use REST or GraphQL; gRPC requires gRPC-Web proxy
| +-- NO
+-- Internal microservice-to-microservice communication?
| +-- YES -> gRPC is ideal. Continue to streaming type selection.
| +-- NO
+-- Polyglot environment needing strict contracts?
| +-- YES -> gRPC with shared proto repository
| +-- NO -> Consider REST with OpenAPI for simpler tooling
STREAMING TYPE SELECTION:
+-- Single request, single response (CRUD/query)?
| +-- YES -> Unary RPC
| +-- NO
+-- Server pushes multiple results for one request?
| +-- YES -> Server streaming (search results, event feed)
| +-- NO
+-- Client sends many items, server responds once?
| +-- YES -> Client streaming (batch upload, telemetry)
| +-- NO
+-- Both sides send/receive independently?
+-- YES -> Bidirectional streaming (chat, live sync)
LOAD BALANCING:
+-- Using a service mesh (Istio/Linkerd)?
| +-- YES -> Mesh handles L7 gRPC load balancing automatically
| +-- NO
+-- Have a proxy (Envoy/nginx)?
| +-- YES -> Configure proxy for HTTP/2 gRPC routing
| +-- NO
+-- DEFAULT -> Use client-side round-robin via gRPC name resolver
Step-by-Step Guide
1. Define your proto service contract
Start with the .proto file. This is the single source of truth for your API. Follow Google's AIP naming conventions: services are PascalCase, methods are VerbNoun, messages use PascalCase with snake_case fields. [src2]
syntax = "proto3";
package myapp.orders.v1;
option go_package = "github.com/myorg/myapp/gen/orders/v1";
import "google/protobuf/timestamp.proto";
service OrderService {
rpc CreateOrder(CreateOrderRequest) returns (Order);
rpc GetOrder(GetOrderRequest) returns (Order);
rpc WatchOrders(WatchOrdersRequest) returns (stream OrderEvent);
}
message Order {
string id = 1;
string customer_id = 2;
repeated OrderItem items = 3;
OrderStatus status = 4;
google.protobuf.Timestamp created_at = 5;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_PENDING = 1;
ORDER_STATUS_CONFIRMED = 2;
ORDER_STATUS_SHIPPED = 3;
}
Verify: buf lint proto/ -> no warnings
2. Generate language-specific stubs
Use protoc with language-specific plugins, or use Buf for a modern workflow. [src1]
# Go
protoc --go_out=. --go-grpc_out=. \
--go_opt=paths=source_relative \
--go-grpc_opt=paths=source_relative \
proto/orders/v1/service.proto
# Python
python -m grpc_tools.protoc -I proto \
--python_out=gen --grpc_python_out=gen \
proto/orders/v1/service.proto
# Node.js
grpc_tools_node_protoc \
--js_out=import_style=commonjs,binary:gen \
--grpc_out=grpc_js:gen \
proto/orders/v1/service.proto
Verify: Generated files appear in output directory with correct package names
3. Implement server with interceptors
Add interceptors for cross-cutting concerns. Order matters: recovery outermost so panics don't crash the process. [src4] [src7]
server := grpc.NewServer(
grpc.ChainUnaryInterceptor(
recoveryInterceptor, // Catch panics first
metricsInterceptor, // Track all requests
loggingInterceptor, // Log all requests
authInterceptor, // Validate tokens
),
grpc.KeepaliveParams(keepalive.ServerParameters{
MaxConnectionIdle: 5 * time.Minute,
Time: 2 * time.Hour,
Timeout: 20 * time.Second,
}),
)
reflection.Register(server) // Enable grpcurl debugging
Verify: grpcurl -plaintext localhost:50051 list -> shows registered services
4. Implement proper error handling
Return rich error details using gRPC status codes and the errdetails package. Never expose internal errors to clients. [src8]
st := status.New(codes.InvalidArgument, "order ID is required")
st, _ = st.WithDetails(&errdetails.BadRequest{
FieldViolations: []*errdetails.BadRequest_FieldViolation{
{Field: "id", Description: "must be non-empty"},
},
})
return nil, st.Err()
Verify: grpcurl -d '{"id":""}' localhost:50051 myapp.orders.v1.OrderService/GetOrder -> returns INVALID_ARGUMENT with field violation details
5. Implement client with deadlines and retries
Every RPC call must have a deadline. Configure retry policies declaratively via service config. [src3]
// Always set a deadline on every RPC call
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.GetOrder(ctx, &GetOrderRequest{Id: orderID})
Verify: Call with an unreachable server and confirm retry + timeout behavior
Code Examples
Go: Server Streaming
// Input: WatchOrdersRequest with customer_id
// Output: Stream of OrderEvent messages until client cancels
func (s *orderServer) WatchOrders(
req *WatchOrdersRequest,
stream OrderService_WatchOrdersServer,
) error {
ch := s.eventBus.Subscribe(req.CustomerId)
defer s.eventBus.Unsubscribe(ch)
for {
select {
case event := <-ch:
if err := stream.Send(event); err != nil {
return status.Errorf(codes.Internal, "send failed: %v", err)
}
case <-stream.Context().Done():
return status.FromContextError(stream.Context().Err()).Err()
}
}
}
Python: Unary Client with Error Handling
# Input: order_id string
# Output: Order object or raises appropriate exception
# Requires: grpcio>=1.68.0, grpcio-tools>=1.68.0
import grpc
from orders.v1 import service_pb2, service_pb2_grpc
def get_order(order_id: str) -> service_pb2.Order:
channel = grpc.insecure_channel("localhost:50051")
stub = service_pb2_grpc.OrderServiceStub(channel)
try:
return stub.GetOrder(
service_pb2.GetOrderRequest(id=order_id),
timeout=5.0,
)
except grpc.RpcError as e:
if e.code() == grpc.StatusCode.NOT_FOUND:
print(f"Order {order_id} not found")
elif e.code() == grpc.StatusCode.DEADLINE_EXCEEDED:
print("Request timed out")
raise
Node.js: Bidirectional Streaming
// Input: bidirectional stream of ChatMessage
// Output: bidirectional stream of ChatMessage
// Requires: @grpc/grpc-js@^1.12.0, @grpc/proto-loader@^0.7.0
const grpc = require("@grpc/grpc-js");
const protoLoader = require("@grpc/proto-loader");
const packageDef = protoLoader.loadSync("chat.proto");
const proto = grpc.loadPackageDefinition(packageDef);
const client = new proto.ChatService(
"localhost:50051", grpc.credentials.createInsecure()
);
const call = client.chat();
call.on("data", (msg) => console.log(`Received: ${msg.text}`));
call.on("error", (err) => {
if (err.code === grpc.status.UNAVAILABLE)
console.log("Server unavailable, reconnecting...");
});
call.write({ text: "Hello", sender: "user1" });
Anti-Patterns
Wrong: REST-style resource URLs in gRPC methods
// BAD -- gRPC methods are not HTTP endpoints
service OrderService {
rpc GetOrdersSlashOrderIdSlashItems(GetRequest) returns (Response);
rpc PostOrders(CreateRequest) returns (Response);
}
Correct: Use clear VerbNoun method names
// GOOD -- follow Google AIP naming: VerbNoun
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse);
rpc CreateOrder(CreateOrderRequest) returns (Order);
}
Wrong: Reusing deleted field numbers
// BAD -- field 3 was deleted, now reused with different type
message Order {
string id = 1;
string name = 2;
// was: string old_field = 3; (deleted)
int64 total = 3; // Reuses field number 3!
}
Correct: Reserve deleted field numbers
// GOOD -- reserved prevents accidental reuse
message Order {
string id = 1;
string name = 2;
reserved 3;
reserved "old_field";
int64 total = 4;
}
Wrong: No deadline on RPC calls
// BAD -- no deadline, call can hang forever
resp, err := client.GetOrder(context.Background(), req)
Correct: Always set a deadline
// GOOD -- deadline ensures call fails fast
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.GetOrder(ctx, req)
Wrong: Generic Request/Response for all RPCs
// BAD -- prevents independent evolution of each RPC
message Request { string type = 1; bytes payload = 2; }
service MyService {
rpc DoSomething(Request) returns (Response);
rpc DoOtherThing(Request) returns (Response);
}
Correct: Dedicated request/response per RPC
// GOOD -- each RPC gets its own messages
service MyService {
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
Common Pitfalls
- Enum zero value maps to a real state: Proto3 defaults unset enums to 0. If 0 means ACTIVE, you cannot distinguish "not set" from "intentionally ACTIVE". Fix: always define
ENUM_NAME_UNSPECIFIED = 0;. [src5] - Streaming where unary suffices: Streaming adds complexity (flow control, reconnection, harder load balancing). Fix: only stream when data is genuinely continuous or too large for a single message. [src3]
- Server reflection not enabled: Without reflection,
grpcurlcannot introspect services. Fix: addreflection.Register(server)in Go oradd_reflectionin Python. [src1] - Ignoring backpressure in streams: Sending faster than the receiver processes causes memory exhaustion. Fix: check
Send()return values and respectcontext.Done(). [src3] - Using float/double for monetary values: IEEE 754 causes rounding errors. Fix: use
int64cents orgoogle.type.Money. [src5] - Exposing internal errors to clients: Returning raw DB errors leaks implementation details. Fix: log full error server-side, return sanitized
status.Error(codes.Internal, "internal error"). [src8] - No version in proto package name: Package changes break generated code. Fix: include version from day one:
package myapp.orders.v1;. [src2]
Diagnostic Commands
# List all services on a running gRPC server
grpcurl -plaintext localhost:50051 list
# Describe a specific service and its methods
grpcurl -plaintext localhost:50051 describe myapp.orders.v1.OrderService
# Call a unary RPC with JSON payload
grpcurl -plaintext -d '{"id": "order-123"}' \
localhost:50051 myapp.orders.v1.OrderService/GetOrder
# Health check
grpc_health_probe -addr=localhost:50051
# Lint proto files with Buf
buf lint proto/
# Detect breaking changes against main branch
buf breaking proto/ --against .git#branch=main
Version History & Compatibility
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| gRPC 1.60+ (2024) | Current | Strict keepalive enforcement | Add KeepaliveEnforcementPolicy to server options |
| protoc 3.21+ / protobuf-go v2 | Current | Go module path changed | Migrate from github.com/golang/protobuf |
| protoc 3.15+ (2021) | Stable | optional keyword reintroduced in proto3 | Can use optional for explicit presence tracking |
| gRPC-Go 1.57+ (2023) | Current | grpc.Dial deprecated | Replace with grpc.NewClient |
| gRPC-Web 1.5+ | Current | None | Required for browser clients |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Internal microservice communication | Public APIs consumed by browsers | REST + OpenAPI |
| Strict contract enforcement across teams | Rapid prototyping with frequently changing schemas | REST or GraphQL |
| Low-latency, high-throughput RPC | Simple CRUD with minimal client diversity | REST |
| Real-time bidirectional data (chat, sync) | Occasional webhook-style callbacks | Webhooks or SSE |
| Polyglot services needing shared contract | Team unfamiliar with protobuf tooling | REST + JSON Schema |
| Streaming large datasets (logs, events) | Small payloads with aggressive HTTP caching needs | REST with Cache-Control |
| Mobile-to-backend with bandwidth constraints | IoT devices without HTTP/2 | MQTT or CoAP |
Important Caveats
- gRPC over HTTP/2 multiplexes all RPCs on a single TCP connection — hitting the concurrent stream limit (default 100) causes queuing. Create separate channels for high-throughput paths.
- Long-lived streaming RPCs cannot be load-balanced mid-stream by L4 proxies. Use L7 proxies (Envoy) or client-side balancing. Streams pin to a single backend for their lifetime.
- Proto3 fields are optional by default (zero values not serialized). Use
optionalkeyword (protoc 3.15+) or wrapper types to distinguish "not set" from "set to zero/empty". - gRPC health checking is a separate service (
grpc.health.v1.Health) — implement it for Kubernetes readiness/liveness probes and load balancer health checks. - Channel/stub reuse is critical — creating a new channel per RPC adds connection setup overhead. Share channels across goroutines/threads.