
Introduction
REST over HTTP/1.1: text-based, human-readable, one request per connection at a time. gRPC over HTTP/2: binary, multiplexed, streaming. For chatty service-to-service traffic gRPC usually spends less time on serialization and connection setup. How much depends on payload size and network, so measure your own calls before and after.
The trade-off is real though. You lose browser compatibility, human-readable debugging, and the entire ecosystem of tools that assume JSON. For public APIs, that is a bad trade. For internal service-to-service calls where you control both ends and performance matters, it is not even close.
This guide builds a gRPC microservice in Go. For protobuf syntax itself, the official proto3 language guide is good — no point duplicating it here. We focus on when gRPC actually beats REST, streaming patterns (the killer feature), and the operational pain nobody warns you about.
Assumes Go basics and a general understanding of microservices. For Go concurrency, see our Go Concurrency Patterns guide.
gRPC vs REST: When It Actually Matters
Protocol Buffers encode data as compact binary: field numbers instead of field names, varint integers, no whitespace. Payloads are typically smaller than the equivalent JSON and cheaper to encode and decode. At high message volumes — hundreds of thousands of inter-service calls per second — that difference shows up in your cloud bill. HTTP/2 multiplexes streams over a single connection, compresses headers, and gives you bidirectional communication. No more opening dozens of TCP connections in parallel.
But none of that matters if your services make 50 calls per second. At that volume, JSON over HTTP/1.1 is fine. The overhead is rounding error. gRPC starts justifying its complexity around the point where serialization shows up in your flame graphs or where you need streaming.
Streaming is the killer feature. Not the performance. Server streaming, client streaming, bidirectional — all first-class in gRPC. With REST you would be stitching together WebSockets or Server-Sent Events and writing your own framing protocol. With gRPC the proto file defines the stream and the generated code handles the plumbing.
.proto files as a single source of truth is the other big win. Change a field name and the generated code catches it at compile time. Not at 3 AM when a downstream service starts returning garbage.
Where REST Still Wins
Browsers. JSON is readable. You can curl it, paste it into Slack. Binary protobuf requires tooling to decode. REST has Swagger, Postman, caching proxies that understand HTTP natively. gRPC tooling is improving but not there yet.
The honest answer: gRPC for internal service-to-service calls where performance or streaming matters. REST at the API boundary for external consumers. Most teams should run both, not pick one.
Protocol Buffers: Defining Your Schema
An order management service — creating orders, fetching them, streaming status updates.
syntax = "proto3";
package orders;
option go_package = "github.com/myapp/proto/orders";
// The Order service handles all order-related operations
service OrderService {
// Create a new order
rpc CreateOrder(CreateOrderRequest) returns (OrderResponse);
// Get an existing order by ID
rpc GetOrder(GetOrderRequest) returns (OrderResponse);
// Stream real-time order status updates
rpc WatchOrderStatus(WatchOrderRequest) returns (stream OrderStatusUpdate);
// Batch create orders via client streaming
rpc BatchCreateOrders(stream CreateOrderRequest) returns (BatchOrderResponse);
// Live order processing with bidirectional streaming
rpc ProcessOrders(stream OrderAction) returns (stream OrderEvent);
}
message CreateOrderRequest {
string customer_id = 1;
repeated OrderItem items = 2;
Address shipping_address = 3;
}
message OrderItem {
string product_id = 1;
int32 quantity = 2;
double unit_price = 3;
}
message Address {
string street = 1;
string city = 2;
string state = 3;
string zip_code = 4;
string country = 5;
}
message OrderResponse {
string order_id = 1;
string customer_id = 2;
repeated OrderItem items = 3;
OrderStatus status = 4;
double total_amount = 5;
string created_at = 6;
}
enum OrderStatus {
PENDING = 0;
CONFIRMED = 1;
PROCESSING = 2;
SHIPPED = 3;
DELIVERED = 4;
CANCELLED = 5;
}
message GetOrderRequest {
string order_id = 1;
}
message WatchOrderRequest {
string order_id = 1;
}
message OrderStatusUpdate {
string order_id = 1;
OrderStatus previous_status = 2;
OrderStatus current_status = 3;
string updated_at = 4;
string message = 5;
}
message BatchOrderResponse {
int32 total_created = 1;
int32 total_failed = 2;
repeated string order_ids = 3;
}
message OrderAction {
string order_id = 1;
string action = 2;
}
message OrderEvent {
string order_id = 1;
string event_type = 2;
string details = 3;
string timestamp = 4;
}Five RPC methods covering all four patterns: unary, server stream, client stream, bidirectional. Field numbers are what the binary encoding uses — not the names — so you can rename fields without breaking wire compatibility. repeated means a list.
Generate the Go code. With paths=source_relative, protoc writes the generated files next to the .proto file, so the file has to live in the directory that matches its go_package:
# 1. Install protoc (the compiler) from your package manager or the release page:
# macOS: brew install protobuf Debian/Ubuntu: sudo apt install protobuf-compiler
# Others: https://github.com/protocolbuffers/protobuf/releases
protoc --version
# 2. Install the Go code generators (they must be on your PATH)
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
# 3. Module path + proto location must match go_package
# (github.com/myapp/proto/orders -> ./proto/orders/)
go mod init github.com/myapp
mkdir -p proto/orders # save order_service.proto here
# 4. Generate Go code next to the .proto file
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
proto/orders/order_service.proto
go get google.golang.org/grpc github.com/google/uuid
Two files come out: order_service.pb.go (message types) and order_service_grpc.pb.go (service interface and client stub). Never edit them.
Implementing a gRPC Server
The generated code gives you an interface. You implement the methods. gRPC handles HTTP/2, serialization, and connection management. Here is how the pieces fit together — the struct embeds the unimplemented server (forward-compatibility, so adding new RPC methods to your proto later does not break existing server code), CreateOrder validates the request using gRPC status codes (more precise than HTTP status codes — InvalidArgument, NotFound, PermissionDenied — and consistent across every language), and the main function is minimal: TCP listener, gRPC server, register, serve.
package main
import (
"context"
"log"
"net"
"sync"
"time"
"github.com/google/uuid"
pb "github.com/myapp/proto/orders"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type orderServer struct {
pb.UnimplementedOrderServiceServer
mu sync.RWMutex
orders map[string]*pb.OrderResponse
}
func newOrderServer() *orderServer {
return &orderServer{
orders: make(map[string]*pb.OrderResponse),
}
}
// CreateOrder handles unary RPC for creating a new order
func (s *orderServer) CreateOrder(
ctx context.Context,
req *pb.CreateOrderRequest,
) (*pb.OrderResponse, error) {
// Validate the request
if req.CustomerId == "" {
return nil, status.Error(
codes.InvalidArgument,
"customer_id is required",
)
}
if len(req.Items) == 0 {
return nil, status.Error(
codes.InvalidArgument,
"at least one item is required",
)
}
// Calculate total amount
var total float64
for _, item := range req.Items {
total += item.UnitPrice * float64(item.Quantity)
}
// Create the order
order := &pb.OrderResponse{
OrderId: uuid.New().String(),
CustomerId: req.CustomerId,
Items: req.Items,
Status: pb.OrderStatus_PENDING,
TotalAmount: total,
CreatedAt: time.Now().Format(time.RFC3339),
}
// Store the order
s.mu.Lock()
s.orders[order.OrderId] = order
s.mu.Unlock()
log.Printf("Created order %s for customer %s (total: $%.2f)",
order.OrderId, order.CustomerId, order.TotalAmount)
return order, nil
}
// GetOrder retrieves an order by ID
func (s *orderServer) GetOrder(
ctx context.Context,
req *pb.GetOrderRequest,
) (*pb.OrderResponse, error) {
s.mu.RLock()
order, exists := s.orders[req.OrderId]
s.mu.RUnlock()
if !exists {
return nil, status.Errorf(
codes.NotFound,
"order %s not found", req.OrderId,
)
}
return order, nil
}
func main() {
listener, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
grpcServer := grpc.NewServer()
pb.RegisterOrderServiceServer(grpcServer, newOrderServer())
log.Println("gRPC server listening on :50051")
if err := grpcServer.Serve(listener); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}Connection management and protocol negotiation happen under the hood. You never deal with HTTP/2 framing directly.
Building the gRPC Client
Mirror of the server. Nothing new.
package main
import (
"context"
"log"
"time"
pb "github.com/myapp/proto/orders"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func main() {
// Establish a connection to the gRPC server
conn, err := grpc.NewClient(
"localhost:50051",
grpc.WithTransportCredentials(
insecure.NewCredentials(),
),
)
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
// Create a client from the connection
client := pb.NewOrderServiceClient(conn)
// Set a timeout for the request
ctx, cancel := context.WithTimeout(
context.Background(), 5*time.Second,
)
defer cancel()
// Create a new order
order, err := client.CreateOrder(ctx, &pb.CreateOrderRequest{
CustomerId: "cust-42",
Items: []*pb.OrderItem{
{
ProductId: "prod-101",
Quantity: 2,
UnitPrice: 29.99,
},
{
ProductId: "prod-205",
Quantity: 1,
UnitPrice: 49.99,
},
},
ShippingAddress: &pb.Address{
Street: "123 Main St",
City: "Portland",
State: "OR",
ZipCode: "97201",
Country: "US",
},
})
if err != nil {
log.Fatalf("CreateOrder failed: %v", err)
}
log.Printf("Order created: %s (total: $%.2f)",
order.OrderId, order.TotalAmount)
// Retrieve the order we just created
fetched, err := client.GetOrder(ctx, &pb.GetOrderRequest{
OrderId: order.OrderId,
})
if err != nil {
log.Fatalf("GetOrder failed: %v", err)
}
log.Printf("Fetched order: %s | Status: %s | Items: %d",
fetched.OrderId, fetched.Status, len(fetched.Items))
}
insecure.NewCredentials() is for local development only. Unencrypted gRPC in a shared Kubernetes cluster means any pod in the namespace can sniff the traffic. context.WithTimeout is not optional — without it, a hung server holds your goroutine forever.
The call is client.CreateOrder(ctx, &pb.CreateOrderRequest{...}). No URL construction, no HTTP method, no marshaling. Type-safe all the way down. The compiler catches wrong types and missing fields at build time, not at runtime. This is the part that makes gRPC worth the setup cost even if you do not care about performance.
Streaming: Server, Client and Bidirectional
This is what justifies gRPC's existence for most teams. Not the performance benchmarks. Streaming.
Server Streaming
One request in, a stream of messages back. Order status updates, tailing logs, live event feeds.
package main
import (
"fmt"
"io"
"log"
"time"
pb "github.com/myapp/proto/orders"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// WatchOrderStatus streams real-time status updates to the client
func (s *orderServer) WatchOrderStatus(
req *pb.WatchOrderRequest,
stream pb.OrderService_WatchOrderStatusServer,
) error {
// Verify the order exists
s.mu.RLock()
order, exists := s.orders[req.OrderId]
s.mu.RUnlock()
if !exists {
return status.Errorf(
codes.NotFound,
"order %s not found", req.OrderId,
)
}
// Simulate order status progressing over time
statuses := []pb.OrderStatus{
pb.OrderStatus_CONFIRMED,
pb.OrderStatus_PROCESSING,
pb.OrderStatus_SHIPPED,
pb.OrderStatus_DELIVERED,
}
previousStatus := order.Status
for _, nextStatus := range statuses {
// Check if the client has disconnected
if err := stream.Context().Err(); err != nil {
log.Printf("Client disconnected: %v", err)
return status.Error(
codes.Canceled, "client disconnected",
)
}
// Simulate processing time
time.Sleep(2 * time.Second)
update := &pb.OrderStatusUpdate{
OrderId: req.OrderId,
PreviousStatus: previousStatus,
CurrentStatus: nextStatus,
UpdatedAt: time.Now().Format(time.RFC3339),
Message: fmt.Sprintf(
"Order moved from %s to %s",
previousStatus, nextStatus,
),
}
if err := stream.Send(update); err != nil {
return err
}
log.Printf("Sent status update: %s -> %s",
previousStatus, nextStatus)
previousStatus = nextStatus
}
return nil
}
// BatchCreateOrders is client streaming: read until the client closes, reply once
func (s *orderServer) BatchCreateOrders(
stream pb.OrderService_BatchCreateOrdersServer,
) error {
result := &pb.BatchOrderResponse{}
for {
req, err := stream.Recv()
if err == io.EOF {
return stream.SendAndClose(result)
}
if err != nil {
return err
}
order, err := s.CreateOrder(stream.Context(), req)
if err != nil {
result.TotalFailed++
continue
}
result.TotalCreated++
result.OrderIds = append(result.OrderIds, order.OrderId)
}
}
// ProcessOrders is bidirectional: answer each action as it arrives
func (s *orderServer) ProcessOrders(
stream pb.OrderService_ProcessOrdersServer,
) error {
for {
action, err := stream.Recv()
if err == io.EOF {
return nil // client called CloseSend
}
if err != nil {
return err
}
event := &pb.OrderEvent{
OrderId: action.OrderId,
EventType: action.Action + "_accepted",
Details: fmt.Sprintf("%s queued for %s", action.Action, action.OrderId),
Timestamp: time.Now().Format(time.RFC3339),
}
if err := stream.Send(event); err != nil {
return err
}
}
}stream.Send() pushes one message. Function returns, gRPC closes the stream. The stream.Context().Err() check detects client disconnection — skip it and you waste resources sending updates into the void. On a long-running stream, a missed check can keep the server working for minutes after the client has gone.
Client Streaming and Bidirectional Streaming
Client streaming reverses it: client sends a stream, server responds once. Batch uploads, aggregation. Bidirectional is both at once — independent message streams in each direction. The server side of both (BatchCreateOrders and ProcessOrders) is at the end of server/streaming.go above; without it the embedded UnimplementedOrderServiceServer answers with codes.Unimplemented.
package main
import (
"context"
"io"
"log"
"time"
pb "github.com/myapp/proto/orders"
)
func watchOrder(client pb.OrderServiceClient, orderID string) {
ctx, cancel := context.WithTimeout(
context.Background(), 30*time.Second,
)
defer cancel()
// Start the server stream
stream, err := client.WatchOrderStatus(ctx,
&pb.WatchOrderRequest{OrderId: orderID},
)
if err != nil {
log.Fatalf("WatchOrderStatus failed: %v", err)
}
// Read messages from the stream until it closes
for {
update, err := stream.Recv()
if err == io.EOF {
log.Println("Stream completed")
break
}
if err != nil {
log.Fatalf("Error receiving update: %v", err)
}
log.Printf("[%s] %s -> %s: %s",
update.UpdatedAt,
update.PreviousStatus,
update.CurrentStatus,
update.Message,
)
}
}
// batchCreate shows client streaming: send many requests, get one summary
func batchCreate(client pb.OrderServiceClient, reqs []*pb.CreateOrderRequest) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
stream, err := client.BatchCreateOrders(ctx)
if err != nil {
log.Fatalf("BatchCreateOrders failed: %v", err)
}
for _, req := range reqs {
if err := stream.Send(req); err != nil {
log.Fatalf("Send failed: %v", err)
}
}
summary, err := stream.CloseAndRecv() // tell the server we are done, wait for its reply
if err != nil {
log.Fatalf("CloseAndRecv failed: %v", err)
}
log.Printf("Created %d, failed %d", summary.TotalCreated, summary.TotalFailed)
}
// processOrders shows bidirectional streaming
func processOrders(client pb.OrderServiceClient) {
ctx, cancel := context.WithTimeout(
context.Background(), 60*time.Second,
)
defer cancel()
stream, err := client.ProcessOrders(ctx)
if err != nil {
log.Fatalf("ProcessOrders failed: %v", err)
}
// Send actions in a separate goroutine
go func() {
actions := []*pb.OrderAction{ // pointers: generated messages must not be copied
{OrderId: "order-1", Action: "confirm"},
{OrderId: "order-2", Action: "ship"},
{OrderId: "order-3", Action: "cancel"},
}
for _, action := range actions {
if err := stream.Send(action); err != nil {
log.Printf("Send error: %v", err)
return
}
time.Sleep(time.Second)
}
stream.CloseSend()
}()
// Receive events from the server
for {
event, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
log.Fatalf("Recv error: %v", err)
}
log.Printf("Event: [%s] %s - %s",
event.OrderId, event.EventType, event.Details)
}
}
Notice the goroutine split in the bidirectional example. One goroutine sends, the main goroutine receives. No lock-step ordering. Both directions flow independently. Try doing this with REST. You end up with WebSockets, custom framing, reconnection logic, and a month of debugging edge cases. With gRPC the proto file defines the contract and the generated code handles the plumbing.
Error Handling and Deadlines
Network calls fail in more ways than local function calls. Slow server. Dropped connection mid-response. Partial results before a crash.
gRPC Status Codes
Canonical codes, consistent across every language. The ones you reach for most: InvalidArgument (3), NotFound (5), AlreadyExists (6), PermissionDenied (7). Two codes deserve special attention. DeadlineExceeded (4) — operation ran out of time. Unavailable (14) — transient failure, safe to retry. Internal (13) should be rare. When you use it, make sure you are logging enough to debug it.
Deadlines
Every gRPC call needs a deadline. Not "should have." Needs.
Without one, a hung downstream service holds goroutines open until your caller runs out of memory. Deadlines propagate automatically through the call chain. Service A calls B with a 5-second deadline. B calls C. C inherits whatever time remains. No manual bookkeeping.
func getOrderWithRetry(
client pb.OrderServiceClient,
orderID string,
maxRetries int,
) (*pb.OrderResponse, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
// Each attempt gets its own deadline
ctx, cancel := context.WithTimeout(
context.Background(), 3*time.Second,
)
order, err := client.GetOrder(ctx, &pb.GetOrderRequest{
OrderId: orderID,
})
cancel() // always cancel to avoid context leak
if err == nil {
return order, nil
}
lastErr = err
st, ok := status.FromError(err)
if !ok {
// Not a gRPC error, don't retry
return nil, fmt.Errorf(
"non-gRPC error: %w", err,
)
}
switch st.Code() {
case codes.Unavailable, codes.DeadlineExceeded:
// These are safe to retry
if attempt == maxRetries {
break // out of retries: fall through to the error below
}
backoff := time.Duration(attempt+1) * 500 * time.Millisecond
log.Printf(
"Attempt %d failed (%s), retrying in %v...",
attempt+1, st.Code(), backoff,
)
time.Sleep(backoff)
continue
case codes.NotFound, codes.InvalidArgument,
codes.PermissionDenied:
// These will not get better with retrying
return nil, fmt.Errorf(
"permanent error (code %s): %s",
st.Code(), st.Message(),
)
default:
return nil, fmt.Errorf(
"unexpected error (code %s): %s",
st.Code(), st.Message(),
)
}
}
return nil, fmt.Errorf(
"gave up after %d attempts: %w",
maxRetries+1, lastErr,
)
}Unavailable and DeadlineExceeded are retryable. NotFound and InvalidArgument are not — retrying will not fix them, it will just hammer the server. Without this separation, every transient failure turns into a self-inflicted denial of service. It is an easy mistake to make in hand-written retry loops.
Backoff here is linear for simplicity. Production: add jitter. Otherwise all clients retry at the same instant and you get a thundering herd. The gRPC library also supports declarative retry policies in the service config, so you do not always need to write this by hand.
Service Discovery and Load Balancing
This is where gRPC gets operationally painful.
DNS-Based Discovery
In Kubernetes, DNS-based discovery works out of the box. Each service gets a DNS name. But standard DNS gives you one IP address, and gRPC holds that single connection. All your requests go to one pod. For actual load balancing, you need client-side balancing or a proxy that understands HTTP/2.
Client-Side Load Balancing
gRPC has built-in client-side balancing. Client resolves a service name to multiple addresses, distributes requests.
import (
"fmt"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
_ "google.golang.org/grpc/balancer/roundrobin"
)
func createLoadBalancedClient() (*grpc.ClientConn, error) {
// Use dns:/// scheme for DNS-based discovery
// The round_robin policy distributes calls evenly
conn, err := grpc.NewClient(
"dns:///order-service.default.svc.cluster.local:50051",
grpc.WithTransportCredentials(
insecure.NewCredentials(),
),
grpc.WithDefaultServiceConfig(
`{"loadBalancingConfig":[{"round_robin":{}}]}`,
),
)
if err != nil {
return nil, fmt.Errorf(
"failed to create client: %w", err,
)
}
return conn, nil
}dns:/// triggers gRPC's built-in DNS resolver. round_robin distributes calls evenly across resolved addresses. Good enough to start.
gRPC with Envoy Proxy
Envoy understands HTTP/2 natively. It balances at the request level, not the connection level. This matters because gRPC connections are long-lived — a TCP load balancer routes all requests from one client to one backend. Envoy distributes individual RPCs across backends. You also get request metrics, distributed tracing, circuit breaking, and rate limiting without changing application code.
In Kubernetes, Istio and Linkerd inject Envoy as a sidecar automatically. But running a service mesh to fix gRPC load balancing is a significant operational commitment. Be honest about whether you need it or whether client-side round-robin is enough.
Health Checking
gRPC defines a standard health checking protocol in grpc.health.v1. Implement it and your load balancer knows whether the service is ready for traffic. Minimal setup:
// Replaces main() in server/main.go
import (
"log"
"net"
pb "github.com/myapp/proto/orders"
"google.golang.org/grpc"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
)
func main() {
grpcServer := grpc.NewServer()
// Register the order service
orderSvc := newOrderServer()
pb.RegisterOrderServiceServer(grpcServer, orderSvc)
// Register the health service
healthServer := health.NewServer()
healthpb.RegisterHealthServer(grpcServer, healthServer)
// Mark the service as serving
healthServer.SetServingStatus(
"orders.OrderService",
healthpb.HealthCheckResponse_SERVING,
)
// You can update the status dynamically,
// e.g., mark as NOT_SERVING during shutdown
// healthServer.SetServingStatus(
// "orders.OrderService",
// healthpb.HealthCheckResponse_NOT_SERVING,
// )
listener, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
if err := grpcServer.Serve(listener); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}Kubernetes queries this endpoint directly using the grpc liveness and readiness probe type. Mark NOT_SERVING during graceful shutdown so existing connections drain before the pod terminates.
How the Pieces Fit
DNS or a service registry handles discovery. Client-side resolver and balancer distribute calls. Envoy or an API gateway provides TLS termination and observability. Health checks keep the system self-healing. Deadlines prevent one slow service from cascading into total failure. A lot of moving parts for what used to be "make an HTTP request." Worth it at scale. Overkill for five services.
For the Kubernetes side, see our Kubernetes for Developers guide. For local development, the Docker Compose Guide covers the setup.
Where gRPC Fits, and What to Set Up First
gRPC between your own services: great. gRPC exposed to the public internet: painful. No browser support without grpc-web, terrible error messages for API consumers, and every debugging tool assumes JSON. Pick your battles.
Set deadlines on every call. Put your proto files in a dedicated repository with CI that auto-generates client libraries on every change. Wire in interceptors for logging, metrics, and tracing before you ship — retrofitting observability into a running system is painful and it always gets deprioritized until something breaks at 2 AM. A gRPC call without a deadline is a goroutine leak waiting to happen. That is not a best practice. That is a production incident.