gRPC: Remote Procedure Calls Guide

gRPC is a high-performance Remote Procedure Call (RPC) framework that Google open-sourced in 2015. It lets you call methods on a remote server as if they were local function calls, abstracting away...

Key Insights

  • gRPC uses Protocol Buffers for schema definition and binary serialization, delivering 5-10x better performance than JSON-based REST APIs while enforcing strict contracts between services.
  • The four communication patterns (unary, server streaming, client streaming, bidirectional) give you flexibility that REST can’t match—choose based on your data flow requirements.
  • Use gRPC for internal service-to-service communication where performance matters; stick with REST for public APIs and browser clients until gRPC-Web matures further.

Introduction to gRPC

gRPC is a high-performance Remote Procedure Call (RPC) framework that Google open-sourced in 2015. It lets you call methods on a remote server as if they were local function calls, abstracting away the networking complexity.

The “g” originally stood for “Google,” but each release assigns it a new meaning (gRPC, good, green, etc.). What matters is what it does: enables efficient communication between services using HTTP/2 for transport and Protocol Buffers for serialization.

Why should you care? Modern distributed systems involve dozens or hundreds of services talking to each other. REST works, but it’s verbose, slow to parse, and lacks a formal contract. gRPC solves these problems with binary serialization, multiplexed connections, and strongly-typed interfaces.

Protocol Buffers: The Foundation

Protocol Buffers (protobuf) serve as both the interface definition language and the serialization format for gRPC. You define your data structures and services in .proto files, then generate code for your target language.

Here’s a basic service definition for a user management system:

syntax = "proto3";

package users;

option go_package = "github.com/yourorg/users/pb";

message User {
  int64 id = 1;
  string email = 2;
  string name = 3;
  repeated string roles = 4;
  UserStatus status = 5;
}

enum UserStatus {
  USER_STATUS_UNSPECIFIED = 0;
  USER_STATUS_ACTIVE = 1;
  USER_STATUS_SUSPENDED = 2;
}

message GetUserRequest {
  int64 id = 1;
}

message GetUserResponse {
  User user = 1;
}

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
}

The field numbers (1, 2, 3, etc.) are crucial—they’re how protobuf identifies fields in the binary format. Never reuse or change them after deployment. You can add new fields with new numbers, and old clients will ignore fields they don’t recognize. This gives you backward compatibility without versioning headaches.

Protobuf’s type system includes scalars (int32, int64, string, bytes, bool), enums, nested messages, and collections (repeated fields and maps). It’s strict by design—no dynamic typing means fewer runtime surprises.

Defining Services and Methods

gRPC supports four communication patterns, each suited to different use cases:

syntax = "proto3";

package chat;

message Message {
  string id = 1;
  string sender_id = 2;
  string content = 3;
  int64 timestamp = 4;
}

message SendMessageRequest {
  string room_id = 1;
  string content = 2;
}

message SendMessageResponse {
  Message message = 1;
}

message GetHistoryRequest {
  string room_id = 1;
  int32 limit = 2;
}

message StreamRequest {
  string room_id = 1;
}

message BulkSendRequest {
  string room_id = 1;
  string content = 2;
}

message BulkSendResponse {
  int32 messages_received = 1;
}

service ChatService {
  // Unary: single request, single response
  rpc SendMessage(SendMessageRequest) returns (SendMessageResponse);
  
  // Server streaming: single request, stream of responses
  rpc GetHistory(GetHistoryRequest) returns (stream Message);
  
  // Client streaming: stream of requests, single response
  rpc BulkSend(stream BulkSendRequest) returns (BulkSendResponse);
  
  // Bidirectional streaming: stream both ways
  rpc Chat(stream Message) returns (stream Message);
}

Unary is the simplest—like a REST call. Use it for CRUD operations and simple queries.

Server streaming sends multiple responses for one request. Perfect for fetching large datasets, real-time feeds, or progress updates.

Client streaming accepts multiple requests and returns one response. Use it for file uploads, batch operations, or aggregating client data.

Bidirectional streaming opens a persistent channel for both directions. This is your choice for chat applications, real-time collaboration, or any scenario requiring continuous two-way communication.

Implementing gRPC Services

Let’s implement the chat service in Go. First, generate the code:

protoc --go_out=. --go-grpc_out=. chat.proto

Now implement the server:

package main

import (
    "context"
    "io"
    "log"
    "net"
    "sync"
    "time"

    "google.golang.org/grpc"
    pb "github.com/yourorg/chat/pb"
)

type chatServer struct {
    pb.UnimplementedChatServiceServer
    mu       sync.RWMutex
    messages map[string][]*pb.Message
}

func (s *chatServer) SendMessage(ctx context.Context, req *pb.SendMessageRequest) (*pb.SendMessageResponse, error) {
    msg := &pb.Message{
        Id:        generateID(),
        SenderId:  "user-123", // Would come from auth context
        Content:   req.Content,
        Timestamp: time.Now().Unix(),
    }

    s.mu.Lock()
    s.messages[req.RoomId] = append(s.messages[req.RoomId], msg)
    s.mu.Unlock()

    return &pb.SendMessageResponse{Message: msg}, nil
}

func (s *chatServer) GetHistory(req *pb.GetHistoryRequest, stream pb.ChatService_GetHistoryServer) error {
    s.mu.RLock()
    messages := s.messages[req.RoomId]
    s.mu.RUnlock()

    limit := int(req.Limit)
    if limit == 0 || limit > len(messages) {
        limit = len(messages)
    }

    for i := len(messages) - limit; i < len(messages); i++ {
        if err := stream.Send(messages[i]); err != nil {
            return err
        }
    }
    return nil
}

func (s *chatServer) BulkSend(stream pb.ChatService_BulkSendServer) error {
    var count int32
    for {
        req, err := stream.Recv()
        if err == io.EOF {
            return stream.SendAndClose(&pb.BulkSendResponse{
                MessagesReceived: count,
            })
        }
        if err != nil {
            return err
        }
        // Process each message
        count++
    }
}

func (s *chatServer) Chat(stream pb.ChatService_ChatServer) error {
    for {
        msg, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }
        
        // Echo back with timestamp
        msg.Timestamp = time.Now().Unix()
        if err := stream.Send(msg); err != nil {
            return err
        }
    }
}

func main() {
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }

    server := grpc.NewServer()
    pb.RegisterChatServiceServer(server, &chatServer{
        messages: make(map[string][]*pb.Message),
    })

    log.Println("Server listening on :50051")
    if err := server.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

The UnimplementedChatServiceServer embedding ensures forward compatibility—if you add new methods to the proto, your server won’t break until you implement them.

Building gRPC Clients

Client code mirrors the server patterns. Here’s how to call each RPC type:

package main

import (
    "context"
    "io"
    "log"
    "time"

    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    "google.golang.org/grpc/status"
    "google.golang.org/grpc/codes"
    pb "github.com/yourorg/chat/pb"
)

func main() {
    conn, err := grpc.Dial("localhost:50051", 
        grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        log.Fatalf("failed to connect: %v", err)
    }
    defer conn.Close()

    client := pb.NewChatServiceClient(conn)
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    // Unary call with error handling
    resp, err := client.SendMessage(ctx, &pb.SendMessageRequest{
        RoomId:  "room-1",
        Content: "Hello, gRPC!",
    })
    if err != nil {
        st, ok := status.FromError(err)
        if ok {
            switch st.Code() {
            case codes.DeadlineExceeded:
                log.Println("Request timed out")
            case codes.Unavailable:
                log.Println("Service unavailable, retry later")
            default:
                log.Printf("RPC failed: %v", st.Message())
            }
        }
        return
    }
    log.Printf("Sent message: %s", resp.Message.Id)

    // Server streaming
    historyStream, err := client.GetHistory(ctx, &pb.GetHistoryRequest{
        RoomId: "room-1",
        Limit:  10,
    })
    if err != nil {
        log.Fatalf("GetHistory failed: %v", err)
    }

    for {
        msg, err := historyStream.Recv()
        if err == io.EOF {
            break
        }
        if err != nil {
            log.Fatalf("Stream error: %v", err)
        }
        log.Printf("History: %s", msg.Content)
    }
}

Always use contexts with timeouts. A missing timeout means a stuck RPC can hang forever. The status package gives you structured error information—use it to handle different failure modes appropriately.

Advanced Features

Interceptors let you add cross-cutting concerns like logging, authentication, and metrics. Here’s an authentication interceptor:

func authInterceptor(
    ctx context.Context,
    req interface{},
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (interface{}, error) {
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return nil, status.Error(codes.Unauthenticated, "missing metadata")
    }

    tokens := md.Get("authorization")
    if len(tokens) == 0 {
        return nil, status.Error(codes.Unauthenticated, "missing token")
    }

    userID, err := validateToken(tokens[0])
    if err != nil {
        return nil, status.Error(codes.Unauthenticated, "invalid token")
    }

    // Add user ID to context for handlers
    ctx = context.WithValue(ctx, "user_id", userID)
    return handler(ctx, req)
}

// Apply to server
server := grpc.NewServer(
    grpc.UnaryInterceptor(authInterceptor),
)

For production, always use TLS:

creds, err := credentials.NewServerTLSFromFile("cert.pem", "key.pem")
if err != nil {
    log.Fatalf("Failed to load TLS: %v", err)
}
server := grpc.NewServer(grpc.Creds(creds))

gRPC vs REST: When to Use What

The choice isn’t binary—most systems use both.

Choose gRPC when:

  • Services communicate internally at high volume
  • You need streaming capabilities
  • Latency matters (gRPC is typically 5-10x faster)
  • You want strong typing and generated clients
  • Both ends are services you control

Choose REST when:

  • Building public APIs for third-party consumers
  • Browser clients need direct access (gRPC-Web exists but adds complexity)
  • You need human-readable payloads for debugging
  • Your team lacks gRPC experience and the learning curve isn’t justified
  • Caching at the HTTP layer is important

The performance difference comes from binary serialization (smaller payloads, faster parsing) and HTTP/2 (multiplexing, header compression, persistent connections). In benchmarks, gRPC typically handles 3-10x more requests per second than equivalent REST endpoints.

However, REST’s ubiquity means better tooling, easier debugging with curl, and wider ecosystem support. For a public API, the developer experience of REST usually outweighs gRPC’s performance benefits.

My recommendation: use gRPC for internal service mesh communication where you control both ends, and expose REST endpoints at your edge for external consumers. This gives you the best of both worlds—performance where it matters most, and accessibility where it matters most.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.