gRPC vs REST: Protocol Buffers, Streaming, and Production Trade-offs
gRPC vs REST compares protocol buffers, streaming, and JSON APIs so you can pick a contract that stays stable as your service graph grows in production.
gRPC vs REST decides how your services talk when the graph gets large. When the contract is vague, every team invents a slightly different error and a slightly different timeout. You then debug with logs that do not match. Still, a public API and an internal RPC do not have to use the same style.
REST models resources with HTTP methods and usually JSON. gRPC models methods with protocol buffers on HTTP/2. Because one is easy to read and the other is strict and compact, each fails in its own way. Also, the failure is often in load balancing, deadlines, or schema drift, not in the happy path demo.
What each style is and why it fails
RFC 9110 defines HTTP semantics, which is the base most REST APIs use. You name a resource, you use a method, and you send a body a human can read. When caches, browsers, and curl already speak that, a public API is easier to ship. Then the cost shows up as weak contracts and fat payloads.
gRPC core concepts describe unary calls and three streaming shapes on top of HTTP/2. Protocol Buffers give you a schema with field numbers, not only field names. If you change a number or reuse it, old clients misread the bytes. Therefore the schema file is the API, and a casual edit is a breaking change.
REST fails in production when each service invents its own paging, errors, and idempotency keys. A common mistake I have seen is a POST that sometimes creates and sometimes updates, with no key. Then a retry doubles a charge. After the incident, people add a header that every client implements differently.
gRPC fails when the network path is built for short HTTP requests. Long lived HTTP/2 connections stick to one pod, so a new replica stays idle. Also, a browser cannot speak native gRPC without a translation layer. So a design that is perfect inside the mesh can be a poor fit for a third party app.
How the implementation works
A REST call is a request and a response. First, the client sends a method and a path. Next, it may send a JSON body.
Finally, it reads a status code and a body. While that is simple, nothing forces the body shape except your tests and your docs. Because of that, optional fields appear and vanish without a compiler error.
A gRPC unary call looks similar at a distance, but the bytes are a protobuf message. The stub is generated, so a missing field or a wrong type fails at build time for clients you compile. Then streaming extends the same stub. Also, deadlines travel with the call, which is the habit REST teams often bolt on later with a header.
Streams and deadlines
Server streaming sends many messages for one request, which fits a tail or a watch. Client streaming uploads a sequence. Bidirectional streaming keeps both sides sending, which fits a job queue or a device session.
If you do not need a stream, a unary call is easier to balance and to retry. When a stream stays open for hours, your deploy plan must drain it.
A deadline is the instant the call should die, not a vague timeout on one hop. If each proxy adds its own long wait, the user gives up while the mesh keeps working. Therefore set a deadline at the edge and let it shrink as the call moves inward. Also, cancel the work when the deadline fires, or you waste capacity after the client is gone.
Status, verbs, and caches
REST status codes are shared and coarse, and your body carries the detail. gRPC status codes are a smaller set, and details can ride in trailers. When a gateway maps one to the other, it often flattens a useful error into a generic 500.
Then the client retries a bug. Also, GET in REST can be cached by a proxy, while a gRPC POST style call usually will not.
Idempotency is your job in both styles. A retried create still needs a key the server remembers. If you only document it, some clients will skip it.
So make the key required in the schema or reject the call. Still, a key that lives for five seconds is too short when the client retries for a minute.
Trade-offs for contracts and clients
Use REST when the caller is a browser, a partner, or a script you do not control. Use gRPC when both ends are your services and you want a strict, compact contract. Also, use REST for a cacheable read that sits behind a CDN. If you need a long stream inside the mesh, gRPC is the simpler fit.
| First choice. | When it fits. | If it breaks. |
|---|---|---|
| REST and JSON. | Use it when humans and third parties must call you. | Schemas drift and payloads grow without a build break. |
| gRPC unary. | Use it when internal calls need a strict contract. | Long lived connections can pin load to one pod. |
| gRPC streaming. | Use it when one call should carry many messages. | Deploys must drain open streams or you will cut them. |
| grpc-web or JSON transcode. | Use it when a browser must reach a gRPC service. | You now operate two stacks and two error mappings. |
Versioning is where teams mix the styles badly. A REST path with a version segment and a protobuf package with a version are different promises. Read API versioning before you freeze either one. Also, field changes inside a version still need semantic versioning if you ship generated stubs as a library.
The transport under gRPC is usually HTTP/2, which is why HTTP/2 and HTTP/3 belongs in this decision. One lost packet can stall many RPC streams that share a connection. Meanwhile, REST over HTTP/1.1 opens more connections and avoids that shared stall at the cost of more handshakes. TCP and UDP explains the stall if you later move the edge to QUIC.
Pitfalls and failure modes
Load balancing is the gRPC outage I see most. However, L4 balancers hash a long connection once, so scale out does nothing until clients reconnect. You need L7, or client side balancing that opens a connection per pod. When a pod dies, in flight streams die with it unless you retry the ones that are safe.
Protobuf evolution has sharp edges. You may add a field, and old code will ignore it. If you renumber a field, or change a type, old and new bytes do not agree.
Then you get silent corruption, which is worse than a crash. Also, JSON transcoding can expose names that do not match the field numbers people edit by hand.
REST evolution fails in public. You remove a field and a mobile app that you cannot force upgrade starts to crash. Still, you add a required field and old clients omit it.
Because clients cache responses, a bug fix can take days to show up. Therefore deprecation needs a clock, not a hope.
- First, decide whether the caller is inside your mesh or outside your control.
- Next, write the deadline and the retry rule before you write the handler.
- Then check how a new pod receives traffic if connections are long lived.
- After that, add a field in a test and prove old clients still decode the message.
- Finally, map errors in one place so a retry does not hide a permanent bug.
In my experience, teams stream when a page of JSON would do. While a stream feels modern, it breaks curl, caches, and simple retries. So start unary or REST, and add a stream when the data is truly a sequence. Also, cap stream lifetime so a forgotten client cannot hold a worker all week.
A contract sketch you can copy
The proto below is a unary charge with an idempotency key. It is the shape I want before money moves. Also, the field numbers are the real contract, so they stay stable. If you transcode this to JSON for a partner, keep the same key rule.
syntax = "proto3";
package pay.v1;
service Pay {
rpc Charge (ChargeReq) returns (ChargeResp);
}
message ChargeReq {
string user_id = 1;
int64 cents = 2;
string idempotency_key = 3;
}
message ChargeResp {
string charge_id = 1;
}
A REST twin would be a POST to a resource with the same key in a header or body. Because both can retry, the server must store the key and return the first result. Then a duplicate is a safe replay, not a second charge. Also, document the status you return when the key matches but the body does not.
Generate stubs in CI from the same file you review. When someone edits a number, the diff should look as serious as a database migration. If you hand write models on one side, they will drift. Still, keep a thin adapter at the edge so a public JSON API can stay stable while the mesh uses protobuf.
Performance, scale, and cost
Protobuf is smaller and faster to parse than JSON for the same fields. In an illustrative production range, a busy internal call can spend a visible share of CPU on JSON parse and gzip. If the call rate is high and the messages are fat, gRPC pays that back. Then the win shrinks if your time is spent in the database anyway.
Scale for gRPC is a connection scale problem as much as a request scale problem. Each client may hold a connection to many pods, and each connection holds streams and buffers. Meanwhile, REST clients often open and close, which stresses handshakes and ports instead. Therefore size pools on purpose, and do not let every replica dial every peer at once.
Streaming scale is about duration. A million idle streams is a million sets of state, even when messages are rare. Also, a slow consumer will build a backlog unless you set a window or drop. So bound concurrency, and shed new streams when the host is full.
Cost is CPU, connection memory, and the time you spend on gateways. A JSON public API plus an internal gRPC mesh is a common split, and it costs a transcoding hop. When that hop buffers a whole body, large uploads get expensive. After you pick a style, measure tail latency with a real payload, not an empty ping.
Key Takeaways
- Also use REST when strangers, browsers, or caches must call the API.
- When both ends are yours, gRPC keeps the contract in a schema the compiler checks.
- Because field numbers are the wire contract, never reuse or renumber them.
- If connections are long lived, balance at L7 or the new pods will stay idle.
- Still send a deadline and cancel work when the client is gone.
- Therefore require an idempotency key before you retry a create or a charge.
FAQ
Can I expose gRPC to browsers?
Not in the native form most servers speak, because browsers do not expose the HTTP/2 frames gRPC needs. You can use grpc-web or a JSON transcode at the edge. Also, that proxy is now part of your API, so version it with the same care. Then keep the internal mesh on native gRPC if that is where you want strict stubs.
Is REST too slow for internal calls?
Often it is fast enough, since the database or another hop dominates. JSON costs more CPU and bytes than protobuf when the rate is high. However, a clear REST contract with tight deadlines can beat a sloppy gRPC mesh. If you are not CPU bound on parse, pick the style your team will operate well.
When is streaming the wrong tool?
Use a normal request when you have one answer and you may want to retry it. Streaming is a poor fit for a simple CRUD read. Also, long streams complicate deploys, balances, and cancels. After you feel that pain, a page of results is usually the better API.
What should I check first when a new pod gets no traffic?
First, see whether clients reuse long HTTP/2 connections through an L4 balancer. Next, check that a new pod is in the resolver or the watch list the clients use. Then force a reconnect or enable L7 balancing and watch the split. Finally, confirm health checks fail the pod before you send it real calls.
List your callers and mark which ones you do not control. Keep those on REST, and move one internal hot path to a protobuf contract with deadlines and an idempotency key. After that, fix load balancing before you add streams. If the internal path is not CPU bound, stay on the style you already run well.
Last updated on 15 September 2026.
[…] RPC is usually TCP or QUIC, not raw UDP. gRPC and REST both sit on reliable transports, because a missing byte is a failed call. Meanwhile, DNS for […]
[…] a generated stub can break the build, bump the package major when the generated code breaks. Also, gRPC and REST both need this rule when field numbers or JSON shapes […]