gRPC: comprehension is disposable
JSON is human-readable. You can open a response in your terminal, scan the keys, understand the structure. That's a real advantage — when a human is actually reading it.
Between microservices, nobody is. Service A sends a payload to Service B. No developer is sitting there watching the bytes go by, nodding at field names. The data is serialized, sent over the wire, deserialized, and consumed by code. The entire lifecycle is machine-to-machine. Human comprehension of the format in transit is a feature nobody uses at runtime.
And yet, most backend systems pay for it. Every JSON payload carries field names 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 matches. All of that overhead exists to preserve readability that no one 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 is not free. It costs bytes, CPU cycles, and safety.
Field names are repeated in every payload. {"created_at": 1716480000} carries 12 bytes just for 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 theoretically 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 — no reflection, no field name matching, just positional encoding. The difference is measurable under load.
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 renamed field three PRs ago silently breaks a consumer that nobody noticed until 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's not a nice-to-have. That's a category of bugs that 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 has proper codes (NotFound, InvalidArgument, Internal), and interceptors handle cross-cutting concerns with typed context.
The client side
Here's where the gap gets wider. 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 mistakes before your code 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 gives you a zero value and moves on. The readability of JSON didn't protect you — the lack of a 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 need WebSockets, SSE, or long polling — each with its own plumbing, its own error handling, its own integration story. gRPC streaming is just another method signature.
When readability matters
gRPC is not 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 simpler to debug by hand — you can curl an endpoint and read the response. With gRPC, you need grpcurl and the binary format requires decoding. For third-party integrations where you don't control the client, JSON is the lingua franca.
That's fine. Those are cases where a human is part of the loop — a developer debugging, a consumer integrating, a browser rendering. Readability serves a real audience there.
Comprehension is disposable
The title of this post isn't a provocation. It's an observation about where we spend engineering effort.
Between microservices at runtime, human comprehension of the wire format has zero consumers. No one reads the bytes in transit. No one benefits from field names being spelled out in plain text. No one is comforted by the fact that the payload is valid UTF-8.
JSON optimizes for a reader that 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 — where you read the contract, understand the types, review the changes. That's a document for humans, versioned, diffable, reviewable. The wire format is a document for machines — compact, typed, fast.
gRPC separates these two concerns. REST conflates them, carrying human readability through every layer even when no human is there 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. And at runtime, between services, the answer is no. Comprehension is disposable. Contracts are not.