← blog
Systems

gRPC: comprehension is disposable

JSON is human-readable. You can open a response in your terminal, scan the keys, and figure out the structure. That's genuinely useful, but only when a human is the one reading.

Between microservices, nobody is. Service A sends a payload to Service B. There's no developer sitting there watching the bytes go by and nodding at the field names. The data gets serialized, shipped over the wire, deserialized, and consumed by code. The whole lifecycle is machine to machine. Nobody uses human comprehension of the format in transit at runtime.

And yet most backend systems keep paying for it. Every JSON payload drags field names along as plain strings, repeated in every single message. Every response is UTF-8 text that has to be parsed character by character. Every client guesses the shape of the data and hopes it lines up. All that overhead exists to preserve readability that nobody actually consumes.

gRPC with Protocol Buffers starts from a different premise. If machines are the only audience, design for machines.

The cost of being readable

JSON's readability isn't free. It costs you bytes, CPU cycles, and safety.

Field names get repeated in every payload. {"created_at": 1716480000} spends 12 bytes just on the key. Protobuf encodes the same thing as a field number, a single varint. Multiply that across thousands of requests per second between dozens of services and you're burning bandwidth on labels that only exist so a human could, in theory, read them.

Serialization is slower too. encoding/json in Go uses reflection to map struct fields to JSON keys at runtime. Protobuf uses pre-compiled code instead: no reflection, no field name matching, just positional encoding. Under load, the difference is measurable.

But the real cost is structural. JSON has no contract. You return some keys, the other side decodes into a struct, and if the field names don't match, you get zero values. No error. No warning. A field somebody renamed three PRs ago silently breaks a consumer, and nobody notices until it's in production.

Contracts over conventions

With gRPC, you start with a .proto file:

syntax = "proto3";
 
package user;
 
option go_package = "gen/userpb";
 
service UserService {
  rpc GetUser (GetUserRequest) returns (User);
  rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
}
 
message GetUserRequest {
  string id = 1;
}
 
message User {
  string id = 1;
  string name = 2;
  string email = 3;
  int64 created_at = 4;
}
 
message ListUsersRequest {
  int32 page_size = 1;
  string page_token = 2;
}
 
message ListUsersResponse {
  repeated User users = 1;
  string next_page_token = 2;
}

Run protoc and you get generated Go code: structs, interfaces, client stubs, server stubs. The contract is enforced at compile time. If the server changes a field, the client won't build. That isn't a nice-to-have. It's a whole category of bugs that just stops existing.

The .proto file is where humans read the contract. The wire format is where machines execute it. Those are two separate concerns, and gRPC keeps them separate.

The Go server side

Implementing a gRPC server in Go feels natural. You embed the generated interface and fill in the methods:

type server struct {
	userpb.UnimplementedUserServiceServer
	store Store
}
 
func (s *server) GetUser(ctx context.Context, req *userpb.GetUserRequest) (*userpb.User, error) {
	user, err := s.store.FindByID(ctx, req.GetId())
	if err != nil {
		return nil, status.Errorf(codes.NotFound, "user %s not found", req.GetId())
	}
 
	return &userpb.User{
		Id:        user.ID,
		Name:      user.Name,
		Email:     user.Email,
		CreatedAt: user.CreatedAt.Unix(),
	}, nil
}

No path param parsing. No json.Marshal. No hand-crafted error envelopes. The types are generated, the error model comes with proper codes (NotFound, InvalidArgument, Internal), and interceptors handle the cross-cutting stuff with typed context.

The client side

This is where the gap really opens up. A gRPC client in Go:

conn, err := grpc.NewClient("localhost:50051",
	grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
	log.Fatal(err)
}
defer conn.Close()
 
client := userpb.NewUserServiceClient(conn)
 
user, err := client.GetUser(ctx, &userpb.GetUserRequest{Id: "abc-123"})
if err != nil {
	st, _ := status.FromError(err)
	log.Printf("code: %s, message: %s", st.Code(), st.Message())
	return
}
 
fmt.Println(user.GetName())

You call a method, pass a typed request, get a typed response. The compiler catches your mistakes before the code ever runs.

With REST:

resp, err := http.Get("http://localhost:8080/users/abc-123")
if err != nil {
	log.Fatal(err)
}
defer resp.Body.Close()
 
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
	log.Fatal(err)
}

This works. But you're trusting that the JSON shape matches your struct. If it doesn't, Go hands you a zero value and moves right along. JSON's readability didn't protect you here. The missing contract is what hurt you.

Streaming without workarounds

REST has no native answer for streaming. gRPC supports it out of the box: server streaming, client streaming, bidirectional.

service FeedService {
  rpc Subscribe (SubscribeRequest) returns (stream Event);
}
func (s *server) Subscribe(req *feedpb.SubscribeRequest, stream feedpb.FeedService_SubscribeServer) error {
	for event := range s.events {
		if err := stream.Send(event); err != nil {
			return err
		}
	}
	return nil
}

With REST you'd reach for WebSockets, SSE, or long polling, and each one brings its own plumbing, its own error handling, its own integration story. In gRPC, streaming is just another method signature.

When readability matters

gRPC isn't for everything. If you're building a public API that browsers call directly, REST with JSON is the practical choice. Browsers don't speak gRPC natively.

REST is also easier to debug by hand. You can curl an endpoint and read the response. With gRPC you need grpcurl, and the binary format has to be decoded first. For third-party integrations where you don't control the client, JSON is the lingua franca.

And that's fine. Those are the cases where a human is actually in the loop: a developer debugging, a consumer integrating, a browser rendering. Readability is serving a real audience there.

Comprehension is disposable

The title of this post isn't a provocation. It's an observation about where we spend our engineering effort.

Between microservices at runtime, human comprehension of the wire format has zero consumers. Nobody reads the bytes in transit. Nobody benefits from field names being spelled out in plain text. Nobody feels better because the payload happens to be valid UTF-8.

JSON optimizes for a reader who doesn't exist in that context. Protobuf optimizes for the only audience that does: the machines on both ends.

The .proto file is where comprehension lives. It's where you read the contract, understand the types, and review the changes. It's a document for humans: versioned, diffable, reviewable. The wire format is a document for machines: compact, typed, fast.

gRPC keeps those two concerns apart. REST mashes them together, carrying human readability through every layer even when no human is around to use it.

In a system with 5, 10, 50 services talking to each other, the question isn't whether you can read the payload. It's whether you need to. At runtime, between services, the answer is no. Comprehension is disposable. Contracts aren't.