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
    end

HTTP/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"]
    end

Unary 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"| Svc

HTTP 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 like grpcurl (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 leveluser.v1user.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.
#

← Previous: GraphQL   Next: JWT

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact