What gRPC and protobuf actually are
gRPC is a remote procedure call framework where you write the schema first. You declare a service and its methods in a .proto file, along with the request and response types for each method. The server implements that interface. Clients call a generated stub, so invoking SayHello on a remote machine looks like an ordinary method call on a local object. The framework handles connection management, serialization and transport.
By default, gRPC uses Protocol Buffers both as its interface definition language and as its wire format. A protobuf message is a record made of typed fields, and each field has a numeric tag. In string name = 1, name is the identifier in your code and 1 is the field number that gets encoded on the wire. The field name never goes over the network. Because the tag identifies the field, you can rename fields in code without breaking anything on the wire. A field number can't be changed or reused casually, though.
The protobuf compiler, protoc, reads the .proto file and generates code for each target language. That code includes typed accessors (in C++, name() and set_name()) and methods that serialize a message to raw bytes and parse it back. The gRPC introduction describes this setup: a Java server and Go or Python clients all generate their code from the same .proto file.
This changes what an API contract is. In many JSON setups, the contract is a wiki page or an OpenAPI document that can drift from the code. With gRPC, the contract is a compiled artifact. If a client's generated code doesn't match the field or type it's using, you get a build error instead of a malformed payload at 2 a.m.
# One .proto file is the single source of truth for every language.
cat > helloworld.proto <<'EOF'
syntax = "proto3";
package helloworld;
option go_package = "example.com/helloworld/helloworldpb";
option java_package = "com.example.helloworld";
// The contract: one service, one method, typed request/response.
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
}
message HelloRequest {
string name = 1; // tag 1 goes on the wire, not the word "name"
}
message HelloReply {
string message = 1;
}
EOF
# Java: message classes (the grpc-java plugin adds the service stubs).
mkdir -p gen/java
protoc --java_out=gen/java helloworld.proto
# Go: messages plus client/server stubs
# (requires protoc-gen-go and protoc-gen-go-grpc on PATH).
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
helloworld.proto
# Python: messages plus stubs via the grpcio-tools package.
python -m grpc_tools.protoc -I. \
--python_out=. --grpc_python_out=. \
helloworld.proto
A single helloworld.proto declares the Greeter service and its messages, and protoc compiles it into Java, Go and Python code. The server and clients all build against the same contract.
How it works on the wire: HTTP/2, deadlines, and status codes
Every gRPC call runs as an HTTP/2 stream. Clients keep a small number of long-lived connections and run many concurrent calls over each one, so you avoid a TCP and TLS handshake per request. The trade-off is that a connection-level L4 load balancer pins all of a client's traffic to one backend. You need L7 or client-side load balancing to spread calls across backends.
Errors work differently from REST. A failed call usually still returns HTTP 200. The real result is the grpc-status code sent in the response trailers. If your handlers don't choose a code on purpose, everything comes back as UNKNOWN or INTERNAL. Clients then can't tell "retry later" from "your request is wrong."
| Domain error | Status code | Client should |
|---|---|---|
| Record doesn't exist | NOT_FOUND |
Not retry |
| Malformed or out-of-range input | INVALID_ARGUMENT |
Fix the request |
| Valid request, wrong state (e.g. refunding an unpaid order) | FAILED_PRECONDITION |
Not retry until the state changes |
| Dependency down or overloaded | UNAVAILABLE |
Retry with backoff |
| Time budget ran out | DEADLINE_EXCEEDED |
Treat the outcome as unknown |
Most clients don't set a deadline by default, and a call without one can hang for as long as the connection stays open. Set a deadline on every call. The deadline travels with the request in the grpc-timeout header. If service A passes its incoming call context to its outgoing calls, B receives whatever time is left, not a new budget.
Client ──(deadline 500ms)──▶ Service A ──(~420ms left)──▶ Service B
spends ~80ms working…
Client cancels / deadline hits
◀── cancel ──────────── A's ctx done ◀── cancel ──── B stops work
When the deadline expires or the caller cancels, each downstream service sees its context cancelled and can stop work nobody will use.
import grpc
import orders_pb2, orders_pb2_grpc # generated from orders.proto
import payments_pb2, payments_pb2_grpc # downstream service B
class OrderService(orders_pb2_grpc.OrderServiceServicer):
def __init__(self, payments_stub, db):
self.payments = payments_stub
self.db = db
def RefundOrder(self, request, context):
order = self.db.get(request.order_id)
if order is None:
# Map domain errors to specific codes. abort() raises and ends the RPC.
context.abort(grpc.StatusCode.NOT_FOUND, f"order {request.order_id} not found")
if not order.paid:
context.abort(grpc.StatusCode.FAILED_PRECONDITION, "order is not paid")
# Pass the remaining budget downstream instead of starting a fresh one.
try:
self.payments.Refund(
payments_pb2.RefundRequest(order_id=order.id),
timeout=context.time_remaining(),
)
except grpc.RpcError as e:
if e.code() == grpc.StatusCode.UNAVAILABLE:
context.abort(grpc.StatusCode.UNAVAILABLE, "payments unavailable, retry later")
raise
return orders_pb2.RefundOrderResponse(status="REFUNDED")
# Client side: every call gets an explicit deadline (seconds).
def refund(stub, order_id):
try:
return stub.RefundOrder(orders_pb2.RefundOrderRequest(order_id=order_id), timeout=0.5)
except grpc.RpcError as e:
if e.code() == grpc.StatusCode.DEADLINE_EXCEEDED:
# The refund may or may not have happened; check before retrying.
return None
raise
A Python gRPC client that sets a 500 ms deadline, and a server that returns NOT_FOUND, FAILED_PRECONDITION or UNAVAILABLE and passes its remaining time budget to the downstream payments call.
Why teams use it: where protobuf beats JSON
The main reasons to adopt gRPC are contract discipline and call patterns, not speed. The .proto file is the API, and protoc generates client stubs and server interfaces from it for every language you support. If someone renames a field or changes a return type, consumers fail at build time when they regenerate. The alternative is a 3 a.m. undefined in a JSON handler.
Types are strict as well. JSON has one number type, and many parsers read it as a double, so large 64-bit IDs can lose precision. Dates arrive as strings in whatever format the producer picked. Protobuf declares int64, bool, enums, and well-known types like Timestamp, so every side agrees on the representation.
| Concern | JSON over REST | Protobuf over gRPC |
|---|---|---|
| Contract | Optional (OpenAPI, if maintained) | Required .proto |
| Breaking change detected | At runtime | At codegen/compile |
| 64-bit integers, timestamps | Strings or lossy doubles | Typed fields |
| Field names on the wire | Repeated in every message | Replaced by numeric tags |
The binary encoding is compact for two reasons. Each field is identified by a small numeric tag instead of its name, and numbers are sent as variable-length integers, so the receiver doesn't have to parse decimal text.
Illustrative estimate, not a measurement: take the Person message {name: "Alice", id: 1234, has_ponycopter: true}. Minified JSON is 48 bytes. The protobuf encoding is about 12 bytes: 7 for the name, 3 for the id, and 2 for the bool.
That saving rarely matters on its own. If a request spends 40 ms waiting on a database and 0.1 ms serializing, the encoding is not where your latency goes.
Choosing a call type: unary and the three streaming modes
The call type matters more than encoding. A single .proto service can declare all four. The stream keyword on the request, the response, or both selects the mode, and each mode gets its own generated stub signature.
| Call type | Shape | Good fit | What it replaces |
|---|---|---|---|
| Unary | One request, one response | GetOrder, CreateUser |
An ordinary REST call |
| Server streaming | One request, many responses | Bulk exports, large paginated reads | Page-token loops |
| Client streaming | Many requests, one response | Telemetry or metrics ingestion | Client-side batching endpoints |
| Bidirectional | Independent streams both ways | Config sync, watch connections | Polling, ad hoc WebSocket protocols |
Server streaming lets the server send rows as it reads them, so neither side tracks cursors. A watch stream replaces polling, where you trade latency against wasted requests: the client subscribes once and the server pushes changes. Compared with a hand-rolled WebSocket protocol, you get typed messages, framing, deadlines and status codes without designing them yourself.
Streaming has operational costs:
- Deploys: A long-lived stream pins a client to one instance. Servers need graceful shutdown that drains streams, and clients need to reconnect and resume, ideally from a resume token or revision number.
- Proxy idle timeouts: Load balancers close quiet connections. Configure keepalive pings or send application heartbeats.
- Retries: Automatic retries cannot safely replay a stream that has already delivered messages. Make resumption explicit and idempotent.
- Backpressure: HTTP/2 flow control limits bytes in flight. If your handler buffers without bound, a slow consumer still exhausts memory.
syntax = "proto3";
package orders.v1;
service OrderService {
// Unary: one request, one response. Use for ordinary lookups and writes.
rpc GetOrder(GetOrderRequest) returns (Order);
// Server streaming: the server sends rows as it reads them, with no page tokens.
rpc ExportOrders(ExportOrdersRequest) returns (stream Order);
// Client streaming: the client pushes many events and gets one summary at the end.
rpc IngestEvents(stream OrderEvent) returns (IngestSummary);
// Bidirectional: the client subscribes and acks, and the server pushes changes.
rpc WatchOrders(stream WatchRequest) returns (stream OrderChange);
}
message GetOrderRequest { string order_id = 1; }
message Order { string order_id = 1; string status = 2; int64 revision = 3; }
message ExportOrdersRequest { string customer_id = 1; }
message OrderEvent { string order_id = 1; string kind = 2; int64 ts_unix_ms = 3; }
message IngestSummary { int64 accepted = 1; int64 rejected = 2; }
message WatchRequest {
// On reconnect after a deploy or dropped connection, resume from the last
// revision you applied instead of replaying the whole stream.
int64 resume_from_revision = 1;
}
message OrderChange {
Order order = 1;
// Clients persist this value so a reconnect can resume idempotently.
int64 revision = 2;
}
A single proto3 service declares a unary call and all three streaming modes, and the watch stream carries a revision number so clients can resume after a deploy or a dropped connection.
Evolving schemas safely without breaking clients
On the wire, a protobuf message carries no field names. Each value is encoded with its field number and a wire type, and the generated code maps numbers back to fields. This is what makes evolution possible. A new field gets a new number. Old readers skip numbers they don't recognize and, in most current runtimes, keep them as unknown fields so they survive re-serialization. Adding fields is therefore safe in both directions.
The same mechanism makes some mistakes dangerous. If you delete email = 3 and later add phone = 3, an old client will read phone numbers as email addresses. If you change int64 to int32, values may be silently truncated. No parse error is raised. With JSON, a mismatch usually fails loudly, and a loud failure is easier to deal with than corrupted data that gets persisted downstream. When you remove a field, mark it with reserved 3; and reserved "email";. The compiler will then reject any later attempt to reuse that number or name.
Presence is the second trap. In proto3, a plain scalar that is unset reads back as its zero value: 0, "", or false. A discount of 0 could mean "no discount" or "field never sent", and you cannot tell which. JSON can distinguish a missing key from null from 0. To get that distinction in protobuf, declare the field optional int32 discount = 4;, which generates a has-check, or use a wrapper type such as google.protobuf.Int32Value. Do this for any field where "unset" carries meaning, and decide it before clients depend on the field.
// order.proto, v1: the version older clients were built against
syntax = "proto3";
package shop.v1;
message Order {
string id = 1;
int64 amount_cents = 2;
string email = 3;
int32 discount = 4; // unset and 0 both read back as 0
}
// order.proto, v2: email removed, presence added, new fields appended
syntax = "proto3";
package shop.v1;
import "google/protobuf/wrappers.proto";
message Order {
// Tag 3 and the name stay retired. protoc rejects any attempt to reuse them,
// so an old client can never decode a new field as `email`.
reserved 3;
reserved "email";
string id = 1;
int64 amount_cents = 2; // keep the type: int64 -> int32 would truncate silently
// Adding `optional` keeps the wire format the same and generates a has-check,
// so "never sent" can be told apart from an explicit 0.
optional int32 discount = 4;
// A wrapper type is the other way to get presence (a null message means unset).
google.protobuf.Int32Value loyalty_points = 5;
// New data gets a new number. v1 readers skip tag 6 as an unknown field.
string phone = 6;
}
A before-and-after Order schema: the removed email field's tag and name are reserved, discount gets explicit presence through optional, and new fields take fresh tag numbers so v1 clients can still decode v2 messages.
Implementing gRPC microservices: repo layout, codegen, and CI
…the first release ships, because adding presence to a field later changes its generated API.
Ownership and layout
Put .proto files in one place: either a dedicated schema repository or a shared proto module inside a monorepo. Give each package an owning team, recorded in a CODEOWNERS-style file, so every change to payments.v1 gets reviewed by the people who will support it. Name packages with a version suffix. That way a deliberate breaking change becomes a new v2 package that can run alongside v1, rather than an edit made in place.
Codegen and CI
Run code generation as part of the build. Don't commit generated files that have been edited by hand. Pin the protoc version and every language plugin version in one shared place. Different plugin versions can produce different generated code from the same schema, and that drift shows up as confusing runtime mismatches between repos. In CI, compare each change against the last released schema and fail on wire-incompatible edits, such as a changed field number or type, or a removed field that wasn't reserved.
Day-two operations
gRPC keeps long-lived HTTP/2 connections and sends many requests over each one. An L4 load balancer chooses a backend once per connection, not per request, so traffic can pile up on a few pods while new replicas get almost nothing. Use client-side balancing or an L7 proxy that understands HTTP/2 and balances individual requests.
You can't read binary payloads by looking at them. Enable server reflection in non-production environments, or publish descriptor sets (--descriptor_set_out) so gRPC-aware CLI clients can decode traffic. Also make sure your logging middleware decodes messages to text before writing them out, with sensitive fields redacted.
gRPC vs REST: decision framework, alternatives, and cost
Use gRPC where you control both ends of the call: internal, polyglot service-to-service traffic, high-call-volume paths, and anything streaming-heavy. Keep REST/JSON for public APIs, third-party integrations, browser clients, and low-traffic admin endpoints, where a URL and curl are worth more than compact payloads. Browsers cannot call standard gRPC directly, so web clients need a gRPC-Web proxy or a JSON transcoding layer. Running both styles in one system is normal: gRPC inside, REST at the edge. Other options include REST with OpenAPI-generated clients, GraphQL for client-driven aggregation, and asynchronous messaging when callers don't need an immediate answer.
| Dimension | gRPC + protobuf | REST + JSON |
|---|---|---|
| Contract | .proto file is required and is the source of truth |
Optional (OpenAPI), often drifts from code |
| Tooling | Requires codegen in every language and build | Any HTTP client works |
| Browser support | Needs a proxy or translation layer | Native |
| Streaming | Client, server, and bidirectional built in | SSE, WebSockets, or polling bolted on |
| Debuggability | Binary payloads need schema-aware tools | Human-readable payloads |
| Load balancing | Long-lived HTTP/2 connections need L7 or client-side balancing | Per-request balancing works with L4 |
| Evolution | Field numbers and breaking-change checks in CI | Convention and discipline |
Cost. You pay upfront for codegen pipelines, schema review, and decoding tools, and ad hoc exploration is slower. The savings are recurring: you don't hand-write clients, type mismatches cause fewer integration bugs, CI catches contract breaks before they ship, and teams coordinate through a reviewed schema instead of meetings. Don't migrate for speed alone. If serialization isn't your bottleneck, you take on all of these costs and get none of the performance benefit you migrated for.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to grpc microservices, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.