gRPC #
When hundreds of microservices communicate thousands of times per second, small per-request overhead accumulates into a big problem. Slow JSON parsing, repetitive HTTP headers, expensive connection setup — all of this drove Google to build gRPC. But gRPC isn’t just “faster REST” — it’s a different communication paradigm: contract-first, binary, streaming-native, and designed from the start for service-to-service communication in internal environments. This article covers gRPC from the basics to production-ready: how Protocol Buffers work, the four streaming patterns, backward compatibility, error handling, interceptors, and when you should just stick with REST.
Why gRPC Exists #
Google runs billions of requests per day among thousands of internal services. Since the early 2000s, they built an internal RPC system called Stubby that handled that scale for over a decade. When containers and microservices became popular in the industry, Google decided to release an open-source version of Stubby — that’s gRPC, released publicly in 2015.
The problems gRPC was designed to solve:
REST problems for internal communication:
1. JSON parsing overhead
REST: JSON parsing takes ~5-10x longer than Protobuf deserialization
For services calling each other hundreds of times per second,
this becomes a real bottleneck
2. HTTP/1.1 connection overhead
REST: every request opens a new connection (or waits for an available one)
HTTP/2 (gRPC): one connection, many concurrent requests (multiplexing)
3. No enforced contract
REST: documentation can go out of sync with implementation
gRPC: the .proto file is a compiled contract — mismatches error immediately
4. Streaming isn't natural
REST: polling or WebSocket feels like a hack
gRPC: streaming is a first-class citizen
gRPC’s Two Foundations: HTTP/2 and Protocol Buffers #
HTTP/2 — More Than Just an Upgrade #
flowchart LR
subgraph HTTP1["HTTP/1.1"]
direction TB
C1["Client"]
S1["Server"]
C1 -->|"Request 1"| S1
S1 -->|"Response 1"| C1
C1 -->|"Request 2 — wait first"| S1
S1 -->|"Response 2"| C1
end
subgraph HTTP2["HTTP/2 gRPC"]
direction TB
C2["Client"]
S2["Server"]
C2 -->|"Stream 1: Request A"| S2
C2 -->|"Stream 2: Request B"| S2
C2 -->|"Stream 3: Request C"| S2
S2 -->|"Responses A, B, C concurrent"| C2
endHTTP/2 gives gRPC three main advantages: multiplexing (many requests over one TCP connection without head-of-line blocking), header compression with HPACK (repeated headers are compressed), and the foundation for native streaming.
Protocol Buffers — The Compiled Contract #
Protocol Buffers (Protobuf) is the binary serialization format that serves as gRPC’s contract definition language. Unlike JSON, which is human-readable but verbose, Protobuf is designed for machine efficiency.
// user_service.proto
syntax = "proto3";
package user.v1;
// Service definition — this generates the client and server stubs
service UserService {
// Unary RPC
rpc GetUser(GetUserRequest) returns (UserResponse);
// Server streaming — the server sends many responses for one request
rpc ListUsers(ListUsersRequest) returns (stream UserResponse);
// Client streaming — the client sends many requests, the server replies with one
rpc CreateUsers(stream CreateUserRequest) returns (CreateUsersResponse);
// Bidirectional streaming
rpc SyncUsers(stream SyncRequest) returns (stream SyncResponse);
}
message GetUserRequest {
string id = 1; // field number — DO NOT change after the schema is in use
}
message UserResponse {
string id = 1;
string name = 2;
string email = 3;
int64 created_at = 4;
UserStatus status = 5;
}
enum UserStatus {
USER_STATUS_UNSPECIFIED = 0; // there's always a default value of 0
USER_STATUS_ACTIVE = 1;
USER_STATUS_SUSPENDED = 2;
}
Why Protobuf is more efficient than JSON:
Serialization comparison of the same data:
JSON (89 bytes, text):
{"id":"usr_123","name":"Budi Santoso","email":"[email protected]","created_at":1706352000}
Protobuf (~40 bytes, binary):
[field 1: "usr_123"][field 2: "Budi Santoso"][field 3: "[email protected]"][field 4: 1706352000]
~55% smaller, deserialization ~5-7x faster.
For 1 million requests per day: saves ~50 MB of transfer + CPU overhead.
The Four gRPC Communication Patterns #
flowchart TD
subgraph Unary["Unary RPC"]
UC["Client"] -->|"1 Request"| US["Server"]
US -->|"1 Response"| UC
end
subgraph SS["Server Streaming"]
SC["Client"] -->|"1 Request"| SSS["Server"]
SSS -->|"Response 1... 2... 3..."| SC
end
subgraph CS["Client Streaming"]
CC["Client"] -->|"Request 1... 2... 3..."| CSS["Server"]
CSS -->|"1 Response once all received"| CC
end
subgraph Bi["Bidirectional Streaming"]
BC["Client"] <-->|"Requests & Responses\nrunning concurrently"| BS["Server"]
endUnary is the most common — like REST, but binary. Good for regular CRUD, single-result operations, the majority of service-to-service use cases.
Server Streaming fits large data exports (send chunk by chunk, don’t load everything into memory), live feeds or progress updates, searches with many results.
// Example server streaming for exports
rpc ExportTransactions(ExportRequest) returns (stream TransactionRecord);
// The server sends thousands of transactions one by one
// The client starts processing while receiving — more efficient than waiting for all
Client Streaming fits large file uploads in chunks, bulk inserts or batch processing, aggregating data sent incrementally.
Bidirectional Streaming for real-time communication (chat, live collaboration), continuously running state synchronization between two services, systems needing dynamic negotiation.
Protobuf Backward Compatibility — Rules That Must Never Be Broken #
This is the most critical aspect of managing gRPC in production. The field number is a field’s identity in Protobuf — it determines how binary data is decoded, not the field name.
// ✓ SAFE: Adding new fields with new field numbers
message UserResponse {
string id = 1;
string name = 2;
string email = 3;
string phone_number = 4; // new — old clients ignore this field
UserStatus status = 5; // new — old clients get the default value
}
// ✗ BREAKING: Swapping or changing field numbers
message UserResponse {
string id = 1;
string email = 2; // DANGER: field number 2 was previously "name"
string name = 3; // DANGER: old clients decode field 2 as "name"
} // but now it holds "email"
// ✗ BREAKING: Changing to an incompatible data type
message UserResponse {
int32 id = 1; // DANGER: changed from string to int32
}
// ✓ CORRECT: Removing fields safely
message UserResponse {
string id = 1;
reserved 2; // field number 2 must never be reused
reserved "name"; // the name "name" must never be reused
string email = 3;
}
Summary of the rules:
ALWAYS SAFE:
✓ Adding new fields with new, never-used field numbers
✓ Removing fields (marking them reserved)
✓ Renaming fields (the field number matters, not the name)
NEVER ALLOWED:
✗ Changing or reusing existing field numbers
✗ Changing a field's data type to an incompatible type
✗ Changing a field from singular to repeated or vice versa
Error Handling in gRPC #
gRPC doesn’t use HTTP status codes like REST. It has its own status code system more specific to RPC.
The most common gRPC Status Codes:
OK (0) → Success
CANCELLED (1) → Request cancelled by the client (deadline exceeded)
INVALID_ARGUMENT (3) → Invalid input (like 400 in REST)
DEADLINE_EXCEEDED (4)→ Deadline expired before the operation finished
NOT_FOUND (5) → Resource not found (like 404 in REST)
ALREADY_EXISTS (6) → Resource already exists (like 409 in REST)
PERMISSION_DENIED (7)→ No permission (like 403 in REST)
RESOURCE_EXHAUSTED(8)→ Rate limit or quota exhausted (like 429 in REST)
INTERNAL (13) → Internal server error (like 500 in REST)
UNAVAILABLE (14) → Service unavailable (like 503 in REST)
UNAUTHENTICATED (16) → Not authenticated (like 401 in REST)
// CORRECT: Use the proper gRPC status codes
func (s *userServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.UserResponse, error) {
user, err := s.db.FindUser(req.Id)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, status.Errorf(codes.NotFound,
"user with id %s not found", req.Id)
}
return nil, status.Errorf(codes.Internal, "internal error: %v", err)
}
return toProto(user), nil
}
// ANTI-PATTERN: Returning raw Go errors → produces UNKNOWN status codes
func (s *userServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.UserResponse, error) {
user, err := s.db.FindUser(req.Id)
if err != nil {
return nil, err // the client only gets UNKNOWN — not informative
}
return toProto(user), nil
}
Deadlines and Timeouts — Mandatory on Every Call #
Without deadlines, one slow service can cause cascading failure across the entire system through thread pool exhaustion.
sequenceDiagram
participant A as Service A
participant B as Service B
participant C as Service C
Note over A,C: Without deadlines — cascading failure
A->>B: GetOrder (no deadline)
B->>C: GetInventory (no deadline)
Note over C: C is stuck or slow
C--xB: No response
Note over B: B waits forever, thread pool exhausted
B--xA: No response
Note over A: A is unresponsive too
Note over A,C: With deadlines — fail fast
A->>B: GetOrder (deadline: 500ms)
B->>C: GetInventory (deadline: 300ms from remaining time)
Note over C: C times out after 300ms
B-->>A: DEADLINE_EXCEEDED — fast, no cascade// CORRECT: Always set a deadline
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
resp, err := client.GetUser(ctx, req)
// CORRECT: Propagate the context — don't create a new one mid-request-chain
func (s *orderServer) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.OrderResponse, error) {
// Use the same ctx (it already has the deadline from upstream)
inventory, err := s.inventoryClient.GetInventory(ctx, &pb.GetInventoryRequest{
ProductId: req.ProductId,
})
// ...
}
Never create a new context.Background() mid-request-chain to avoid deadline propagation. This defeats the deadline’s purpose entirely — downstream services won’t know the upstream already timed out and will keep processing requests whose results will never be used.Interceptors — Cross-Cutting Concerns #
Interceptors in gRPC are the equivalent of middleware in HTTP frameworks. They let you add logic applying to all RPC calls without changing every handler.
// Unary server interceptor — example for logging
func loggingInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
start := time.Now()
resp, err := handler(ctx, req) // call the actual handler
duration := time.Since(start)
if err != nil {
st, _ := status.FromError(err)
log.Printf("gRPC %s | %s | %v", info.FullMethod, st.Code(), duration)
} else {
log.Printf("gRPC %s | OK | %v", info.FullMethod, duration)
}
return resp, err
}
// Register multiple interceptors when creating the server
server := grpc.NewServer(
grpc.ChainUnaryInterceptor(
recoveryInterceptor, // handle panics
authInterceptor, // validate tokens
loggingInterceptor, // log every call
metricsInterceptor, // prometheus metrics
tracingInterceptor, // OpenTelemetry tracing
),
)
Interceptors almost always needed in production: recovery (handle panics), auth (validate tokens), logging (method + status + duration), metrics (counters + latency histograms), and tracing (propagate trace context).
Exposing gRPC to External Consumers #
Browsers don’t support the raw HTTP/2 that gRPC needs. There are two common approaches:
flowchart LR
Browser["Browser"]
Mobile["Mobile App\n(can do native gRPC)"]
GW["API Gateway\n(REST → gRPC transcoding)"]
GWP["gRPC-Web Proxy\n(Envoy)"]
Svc["Backend Service\n(native gRPC)"]
Browser -->|"REST API"| GW
Browser -->|"gRPC-Web"| GWP
Mobile -->|"native gRPC"| Svc
GW -->|"gRPC"| Svc
GWP -->|"gRPC"| SvcHTTP Transcoding is the most pragmatic approach: expose REST endpoints at the API gateway that internally communicates to the backend via gRPC. Consumers use regular REST, the backend stays gRPC.
// Annotation to auto-generate REST endpoints from proto
import "google/api/annotations.proto";
service UserService {
rpc GetUser(GetUserRequest) returns (UserResponse) {
option (google.api.http) = {
get: "/v1/users/{id}" // REST endpoint auto-generated
};
}
}
gRPC Anti-Patterns to Avoid #
// ✗ Anti-pattern 1: Reusing a removed field number
message UserRequest {
string id = 1;
// old_name = 2; ← removed
string new_field = 2; // WRONG — field number 2 was previously used
}
// ✓ Solution: Reserved field numbers
message UserRequest {
string id = 1;
reserved 2;
reserved "old_name";
string new_field = 3;
}
// ✗ Anti-pattern 2: No deadline
resp, err := client.GetUser(context.Background(), req)
// ✓ Solution: Always set a deadline
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, req)
// ✗ Anti-pattern 3: Exposing gRPC directly to the internet
Internet → gRPC Service (directly)
→ Browsers don't support native gRPC
→ No proper auth layer
→ Binary protocol is hard for consumers to debug
// ✓ Solution: An API Gateway in front
Internet → API Gateway (REST/GraphQL) → gRPC Services (internal)
// ✗ Anti-pattern 4: Overly generic proto schemas
message DoAnythingRequest {
string action = 1;
string entity_id = 2;
bool flag1 = 3;
bool flag2 = 4;
// ... 20 more fields
}
// ✓ Solution: RPCs specific per use case
rpc GetUserProfile(GetUserProfileRequest) returns (UserProfile);
rpc GetUserOrders(GetUserOrdersRequest) returns (UserOrdersResponse);
For debugging binary gRPC, use tools likegrpcurl(like curl but for gRPC),grpcui(a GraphiQL-like UI for gRPC), or Postman which already supports gRPC. Without these tools, debugging gRPC request/responses in production is far harder than REST.
Production-Ready gRPC Checklist #
PROTO DESIGN:
□ Package names use versioning (user.v1, order.v1)
□ Field numbers never changed or reused
□ Removed fields marked reserved (both numbers and names)
□ Enums always start with a 0 value meaning UNSPECIFIED
□ Input messages and output messages separated (not shared)
ERROR HANDLING:
□ All errors use the proper gRPC status codes
□ Raw Go/Java/Python errors never returned directly
□ Error messages informative but not exposing internal details
DEADLINES AND TIMEOUTS:
□ Every client RPC call has a deadline
□ Deadlines propagated to downstream calls — no new contexts created
□ Timeouts calibrated to the expected SLA
INTERCEPTORS:
□ Recovery interceptor handles panics
□ Auth interceptor validates all requests needing authentication
□ Logging interceptor records method, status code, and duration
□ Metrics interceptor sends data to Prometheus
□ Tracing interceptor propagates trace context (OpenTelemetry)
DEPLOYMENT:
□ gRPC not exposed directly to the internet
□ API Gateway or gRPC-Web used for external consumers
□ TLS mandatory in production (mTLS for sensitive service-to-service)
□ Health check endpoint implemented
TOOLING:
□ grpcurl or grpcui available for debugging
□ Proto files version-controlled alongside the code
□ Automatic code generation as part of the build process
Summary #
- gRPC isn’t faster REST — it’s a different paradigm — contract-first, binary, and designed specifically for service-to-service communication in internal environments.
- Protocol Buffers are compiled contracts — field numbers must never be changed or reused. Removed fields must be marked
reserved. This is the most critical rule. - HTTP/2 multiplexing eliminates head-of-line blocking — hundreds of concurrent RPCs over one TCP connection, with header compression significantly reducing overhead.
- Four streaming patterns for four different use cases — Unary for regular CRUD, Server Streaming for data exports/live feeds, Client Streaming for uploads/bulk, Bidirectional for real-time sync.
- Deadlines are mandatory on every RPC call and must be propagated — without deadlines, one slow service can cascade-fail the entire system. Don’t create new
context.Background() mid-request-chain. - Interceptors for all cross-cutting concerns — recovery, auth, logging, metrics, and tracing must exist as interceptors, not reimplemented in every handler.
- Error handling uses gRPC status codes — return
codes.NotFound, codes.InvalidArgument, not raw errors that produce UNKNOWN status codes. - gRPC isn’t suited for public APIs consumed by browsers — use an API Gateway with REST or GraphQL in front; gRPC only for internal communication.
- Versioning at the Protobuf package level —
user.v1 → user.v2 for breaking changes, backward-compatible changes for additive modifications without version bumps. - Observability is more challenging than REST — binary protocols can’t be inspected with curl.
grpcurl, distributed tracing, and structured logging with trace IDs are the minimum requirements.
#
- gRPC isn’t faster REST — it’s a different paradigm — contract-first, binary, and designed specifically for service-to-service communication in internal environments.
- Protocol Buffers are compiled contracts — field numbers must never be changed or reused. Removed fields must be marked
reserved. This is the most critical rule. - HTTP/2 multiplexing eliminates head-of-line blocking — hundreds of concurrent RPCs over one TCP connection, with header compression significantly reducing overhead.
- Four streaming patterns for four different use cases — Unary for regular CRUD, Server Streaming for data exports/live feeds, Client Streaming for uploads/bulk, Bidirectional for real-time sync.
- Deadlines are mandatory on every RPC call and must be propagated — without deadlines, one slow service can cascade-fail the entire system. Don’t create new
context.Background()mid-request-chain. - Interceptors for all cross-cutting concerns — recovery, auth, logging, metrics, and tracing must exist as interceptors, not reimplemented in every handler.
- Error handling uses gRPC status codes — return
codes.NotFound,codes.InvalidArgument, not raw errors that produceUNKNOWNstatus codes. - gRPC isn’t suited for public APIs consumed by browsers — use an API Gateway with REST or GraphQL in front; gRPC only for internal communication.
- Versioning at the Protobuf package level —
user.v1→user.v2for breaking changes, backward-compatible changes for additive modifications without version bumps. - Observability is more challenging than REST — binary protocols can’t be inspected with curl.
grpcurl, distributed tracing, and structured logging with trace IDs are the minimum requirements.