Building gRPC Microservices with Go and Protocol Buffers: Optimizing Inter-Service Communication Performance

When REST APIs Start Falling Short in Microservices

When we first started building distributed systems, our team defaulted to REST over HTTP/1.1 with JSON payloads. With just 2–3 services in the cluster, everything ran smoothly. But once the system expanded to over 15 microservices making continuous inter-service calls, our p99 latency spiked from 12ms to nearly 180ms.

The culprit wasn’t hard to find. Because JSON is a text-based format, the CPU spends substantial cycles parsing strings. On top of that, HTTP/1.1 suffers from head-of-line blocking; every request has to compete for an available TCP socket or pay the penalty of a three-way handshake to establish a new connection. That was the turning point when our team decided to adopt gRPC, Go, and Protocol Buffers across all internal networking.

Under the Hood: How Do gRPC and Protocol Buffers Work?

1. What Problem Does Protocol Buffers (Protobuf) Solve?

Protobuf is Google’s binary serialization mechanism. Instead of sending verbose JSON strings like {"order_id": 101, "status": "PAID"} (which consumes about 34 bytes), Protobuf encodes the entire payload into a byte array of just 6–8 bytes based on a predefined schema in a .proto file.

2. Why Does gRPC Outperform Traditional REST?

  • Multiplexing over HTTP/2: Hundreds of requests and responses can be multiplexed in parallel over a single TCP connection, eliminating connection queue bottlenecks.
  • 60–80% network payload reduction: Binary data combined with header compression (HPACK) yields massive bandwidth savings, especially when throughput reaches tens of thousands of RPS.
  • Strict API contracts: Client and server stubs are automatically generated from the .proto file. The compiler catches type mismatches right at build time instead of waiting for runtime 500 errors.

3. Four Communication Patterns in gRPC

  • Unary RPC: The familiar one-to-one request-response model, similar to a standard REST API call.
  • Server Streaming RPC: The client sends a single request, and the server keeps the connection open to stream a sequence of messages back in real time.
  • Client Streaming RPC: The client streams a sequence of messages to the server and receives a single aggregated response.
  • Bidirectional Streaming RPC: Both client and server read and write independent streams of data simultaneously.

Hands-on: Building an Order Service with Go

We’ll build a realistic order service featuring two workflows: Unary RPC to create orders and Server Streaming RPC to stream shipment status updates in real time.

Step 1: Install the Tools

Install the protoc compiler and two Go code generation plugins via your terminal:

# Install Go code generation plugins for protoc
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

# Add GOPATH/bin to PATH
export PATH="$PATH:$(go env GOPATH)/bin"

Step 2: Initialize the Project and Define the Contract

Set up the project directory:

mkdir -p grpc-order-demo/proto
cd grpc-order-demo
go mod init grpc-order-demo

Define the schema in proto/order.proto:

syntax = "proto3";

package order;
option go_package = "grpc-order-demo/proto/orderpb";

message CreateOrderRequest {
  string customer_id = 1;
  double amount = 2;
  string item_name = 3;
}

message CreateOrderResponse {
  string order_id = 1;
  string status = 2;
}

message TrackOrderRequest {
  string order_id = 1;
}

message OrderStatusUpdate {
  string order_id = 1;
  string step = 2;
  string timestamp = 3;
}

service OrderService {
  // Unary RPC: Create a new order
  rpc CreateOrder (CreateOrderRequest) returns (CreateOrderResponse);

  // Server Streaming RPC: Stream order status in real time
  rpc TrackOrder (TrackOrderRequest) returns (stream OrderStatusUpdate);
}

Step 3: Compile the .proto File to Go

Run the protoc command from the project root:

mkdir -p proto/orderpb
protoc --go_out=. --go_opt=paths=source_relative \
       --go-grpc_out=. --go-grpc_opt=paths=source_relative \
       proto/order.proto

mv proto/order.pb.go proto/order_grpc.pb.go proto/orderpb/
go mod tidy

After this step, you will have two auto-generated files in proto/orderpb containing all the structs and interfaces required for server-client communication.

Step 4: Implement the gRPC Server

Create server/main.go. The server will listen on port :50051 and implement the business logic:

package main

import (
	"context"
	"fmt"
	"log"
	"net"
	"time"

	pv "grpc-order-demo/proto/orderpb"

	"google.golang.org/grpc"
)

type orderServer struct {
	pv.UnimplementedOrderServiceServer
}

func (s *orderServer) CreateOrder(ctx context.Context, req *pv.CreateOrderRequest) (*pv.CreateOrderResponse, error) {
	orderID := fmt.Sprintf("ORD-%d", time.Now().UnixNano()%100000)
	log.Printf("[Server] Received order from %s: %s ($%.2f)", req.GetCustomerId(), req.GetItemName(), req.GetAmount())

	return &pv.CreateOrderResponse{
		OrderId: orderID,
		Status:  "CONFIRMED",
	}, nil
}

func (s *orderServer) TrackOrder(req *pv.TrackOrderRequest, stream pv.OrderService_TrackOrderServer) error {
	stages := []string{"Order received", "Packaging", "Handed over to carrier", "Delivered successfully"}

	for _, stage := range stages {
		update := &pv.OrderStatusUpdate{
			OrderId:   req.GetOrderId(),
			Step:      stage,
			Timestamp: time.Now().Format(time.RFC3339),
		}

		if err := stream.Send(update); err != nil {
			return err
		}
		time.Sleep(800 * time.Millisecond)
	}
	return nil
}

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

	s := grpc.NewServer()
	pv.RegisterOrderServiceServer(s, &orderServer{})

	log.Println("gRPC Server running on :50051...")
	if err := s.Serve(lis); err != nil {
		log.Fatalf("Failed to serve: %v", err)
	}
}

Step 5: Implement the gRPC Client

Create client/main.go to simulate another microservice calling the Order Service:

package main

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

	pv "grpc-order-demo/proto/orderpb"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
)

func main() {
	// Use NewClient for newer Go gRPC versions
	conn, err := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		log.Fatalf("Connection failed: %v", err)
	}
	defer conn.Close()

	client := pv.NewOrderServiceClient(conn)

	// 1. Call Unary RPC with a timeout
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	res, err := client.CreateOrder(ctx, &pv.CreateOrderRequest{
		CustomerId: "CUST-9921",
		Amount:     129.50,
		ItemName:   "Keychron K2 Pro Keyboard",
	})
	if err != nil {
		log.Fatalf("Failed to create order: %v", err)
	}
	log.Printf("[Client] Order created successfully! ID: %s | Status: %s", res.GetOrderId(), res.GetStatus())

	// 2. Call Server Streaming RPC
	log.Println("[Client] Streaming order progress...")
	stream, err := client.TrackOrder(context.Background(), &pv.TrackOrderRequest{OrderId: res.GetOrderId()})
	if err != nil {
		log.Fatalf("Failed to start stream: %v", err)
	}

	for {
		update, err := stream.Recv()
		if err == io.EOF {
			log.Println("[Client] Order delivered. Stream closed.")
			break
		}
		if err != nil {
			log.Fatalf("Failed to read from stream: %v", err)
		}
		log.Printf("-> [%s] %s", update.GetTimestamp(), update.GetStep())
	}
}

Field Experience: Debugging and Testing Binary Payloads

Because gRPC transmits binary data, you cannot simply use curl to test endpoints like you would with REST. Instead, install grpcurl to interact with services directly via CLI.

When you need to convert Protobuf payloads to JSON for frontend integration or debugging logs, manually formatting data often leads to missing brackets or syntax errors. You can quickly use online utilities like the JSON Formatter on toolcraft.app to format and validate syntax accurately without installing bulky IDE plugins.

When to Choose gRPC vs. When to Stick with REST

gRPC and REST complement each other rather than compete directly. Practical experience from large-scale backend systems shows:

  1. Choose gRPC for: Internal inter-service communication (East-West traffic), IoT/streaming workloads demanding sub-10ms latency, or polyglot environments (Go, Java, Python) requiring a strictly unified schema.
  2. Stick with REST for: Public-facing APIs for third parties, direct browser-to-backend communication (avoiding the overhead of a gRPC-Web proxy), or simple CRUD services requiring rapid development.

Adding Protobuf and gRPC to your Go tech stack equips you to eliminate network bandwidth bottlenecks as your system scales to tens of thousands of concurrent connections.

Share: