RPC vs REST β Rethinking the API Design Fork from the Wire Up
What actually separates RPC from REST: HTTP/2 frames and protobuf bytes, contracts and schema evolution, error design, caching, authn/authz, observability, and the operational traps.
"gRPC between internal services, REST for the public API." That conclusion is probably right, but few people can explain why. Stop at "gRPC is faster" and you end up shipping a public gRPC API that no CDN can cache, or introducing protobuf on a sub-millisecond internal hop and improving nothing at all.
The sections below compare RPC and REST layer by layer: design philosophy, the bytes on the wire, schema evolution, error design, and operations. At each layer, the question is which constraints you accept, what they buy you, and what they cost.
Think of a government office
- RPC is phoning a specific clerk with a request: "Tanaka-san, please push this application through." It's fast because both sides already know the procedure name. But when the clerk changes or the procedure is renamed, every caller must be updated. And when the line drops mid-call, you have no idea whether the work was done.
- REST is reading and writing a filing cabinet using a standard form. The location (URL) and the operations (GET / PUT / DELETE) mean the same thing everywhere, so anyone, intermediaries included, can handle them. A photocopier (CDN) may keep copies; the front desk (a proxy) can sort things by looking at them. The price is translating verbs ("cancel this application") into nouns.
So what drives the choice is less speed than where the semantics live: inside your application (RPC) or inside the protocol (REST). The former buys expressiveness; the latter buys reusable intermediaries.
1. The question "RPC or REST?" doesn't type-check
1.1 The two sides aren't the same kind of thing
- RPC (Remote Procedure Call) is a programming model: write code that crosses the network as if it were a local function call. gRPC, JSON-RPC, Thrift, and Cap'n Proto are all implementations of that idea.
- REST (Representational State Transfer) is the architectural style Roy Fielding defined in his 2000 dissertation. It is a theory β "impose these constraints and you get these properties (scalability, independent evolvability, visibility)" β not a technology.
One is a programming model and the other a set of constraints, so comparing them as they are leaves people talking past each other. Restate the question first:
| The question people ask | What is actually being compared |
|---|---|
| "RPC or REST?" | Do you model the API as a set of procedures or as a set of resources? |
| "gRPC or a REST API?" | Protobuf-over-HTTP/2 versus JSON-over-HTTP as concrete stacks |
| "Which one is faster?" | Performance characteristics of a wire format and a multiplexing scheme |
These three axes are independent. You can follow REST's resource model while shipping protobuf (Google's APIs do exactly that), and you can design resource-oriented APIs on top of gRPC. For the same reason, the inference "it's HTTP + JSON, therefore it's REST" rarely holds.
1.2 Most "REST APIs" in the wild are RPC over HTTP
POST /api/getUserProfile, POST /api/sendEmail, GET /api/searchOrders?q=.... The URL contains a procedure name, the HTTP method carries no meaning, and the response contains no next transitions. That is RPC using HTTP as a transport, not REST.
There is nothing wrong with that; for many teams it is the right answer. The damage comes from believing it is REST: the team never collects REST's benefits (caching, intermediaries, a uniform interface), and because the name hides what the API actually is, nobody discusses the design trade-off.
1.3 The Richardson Maturity Model β how far up HTTP do you climb?
Leonard Richardson's four-level model (QCon 2008, popularised by Martin Fowler in 2010) is still a useful ruler for measuring that gap.
- Level 0: HTTP as a tunnel for bytes. XML-RPC, SOAP, and GraphQL (which effectively runs on a single
/graphqlendpoint) all live here. - Level 1: URLs identify resources, but the operation still lives in the body.
- Level 2: HTTP methods and status codes carry semantics. This is what "REST API" means in practice, and it is where caching, idempotency, and intermediary support start paying off.
- Level 3: HATEOAS (Hypermedia as the Engine of Application State) β responses embed the available next transitions as links.
Fielding himself pushed back hard in his 2008 post "REST APIs must be hypertext-driven", insisting that anything below Level 3 should not be called REST. In practice, though, twenty years of APIs have shown that Level 2 already collects most of the benefit.
2. The RPC lineage β forty years chasing the same dream
Birrell and Nelson's 1984 paper, aiming to make distributed computation easy, took as a design principle that a remote call's semantics should be as close as possible to a local call's. Systems built on that principle failed at scale more than once, and each time the correction moved towards giving up transparency.
2.1 Birrell & Nelson (1984) β the Cedar implementation
Andrew Birrell and Bruce Nelson's Implementing Remote Procedure Calls (ACM TOCS 2(1), 1984), written at Xerox PARC, reports the design decisions and performance of an RPC package built for the Cedar programming environment. As the paper itself notes, the idea of RPC had been discussed in the literature since at least 1976, and Nelson's 1981 doctoral dissertation had examined the design options in depth. Almost every element of a modern RPC system is already there:
- Stubs β client-side proxy functions that package the arguments, generated automatically from the interface definition by a tool called Lupine.
- Marshalling / unmarshalling β converting language values into bytes.
- Binding β resolving a service name to an instance (today: service discovery).
- Call semantics β the paper guarantees that if a call returns, the procedure ran precisely once, and that if an exception is reported, it ran once or not at all, without telling the caller which. That is at-most-once semantics.
That last point has not changed in forty years. Without extra machinery (idempotency keys, a transactional outbox), you cannot guarantee that a network call executed exactly once.
2.2 Sun RPC and XDR β RPC becomes infrastructure
In the late 1980s Sun's ONC RPC (RFC 1050 β RFC 1831 β RFC 5531) spread widely as the substrate for NFS. Its XDR (External Data Representation, RFC 4506) is the direct ancestor of "language-independent, typed, binary encoding". Write the interface in an IDL (interface definition language), generate code β exactly the relationship .proto has with protoc today.
2.3 The distributed-object era β CORBA, DCOM, Java RMI
The 1990s swung towards treating objects as transparently remote: CORBA (OMG; 1.0 in 1991, IIOP from 2.0), Microsoft's DCOM (1996), Java RMI (JDK 1.1, 1997).
None of them stuck at scale. Complexity was part of it, but the deeper cause is the "transparency lie" covered in Β§3: something that looks exactly like a local object reference hangs for tens of seconds without returning anything during a network partition. That experience drove architects away from RPC.
2.4 XML-RPC β SOAP β WS-* β on HTTP, but not using HTTP
XML-RPC (1998) and SOAP (1999β2000) chose HTTP because it traversed firewalls. But they used exactly one verb, POST, and put all semantics inside an XML envelope β textbook Level 0.
Then came WS-Security, WS-ReliableMessaging, WS-Transaction, and the rest of the WS-* pile, until interoperability collapsed under its own weight. REST's rise was most likely, above all, a reaction to that complexity.
2.5 gRPC onward β RPC that gave up on transparency
In 2015 Google released gRPC, a redesign of its internal Stubby (1.0 in 2016; accepted by the CNCF in February 2017, and still incubating at the time of writing). The same year Facebook published GraphQL. Twitch open-sourced Twirp in 2018, Buf shipped Connect in 2022, and the TypeScript world uses tRPC.
Unlike 1990s distributed objects, modern RPC makes the remoteness visible through types and API shape.
- Deadlines are part of the protocol vocabulary (
grpc-timeout). They are not mandatory, but the protocol gives you a place to enforce "every call sets one" as an organisational rule. - Failure appears in the type signature:
(resp, error)in Go,Resultin Rust. - Streaming is expressed as a distinct type, never hidden.
3. Why RPC kept failing β the transparency lie
3.1 A Note on Distributed Computing (1994)
Jim Waldo and colleagues at Sun Labs wrote one of the most important short papers in distributed systems (TR-94-29). They gave four reasons why erasing the local/remote distinction fails in principle.
| Difference | Why it breaks things | Modern mitigation |
|---|---|---|
| Latency | Local calls are nanoseconds; remote calls are microseconds to milliseconds. A 10^3β10^6 gap is a difference of kind, not degree | Deadline propagation, batching, async APIs |
| Memory access | Pointers are meaningless in another address space | Force pass-by-value (marshalling); never model references as types |
| Partial failure | Locally, caller and callee die together. Remotely, one side dies β or its state becomes unknowable | Timeouts, retries + idempotency, circuit breakers |
| Concurrency | A remote object is hit by many clients at once | Statelessness, optimistic locking (ETag / version) |
Partial failure is the nastiest. When no response arrives, the client cannot distinguish "the request never landed" from "it was processed and the response was lost". That ambiguity is what forces the question "may I retry?", and that question is why idempotency has to be designed.
3.2 The eight fallacies of distributed computing
L. Peter Deutsch collected seven of these around 1994, and James Gosling added the eighth. The list still works as a checklist for evaluating any RPC design.
- The network is reliable.
- Latency is zero.
- Bandwidth is infinite.
- The network is secure.
- Topology doesn't change.
- There is one administrator.
- Transport cost is zero.
- The network is homogeneous.
REST's stateless, cacheable, and layered-system constraints address these at the architecture level. RPC frameworks answer the same problems in library code: deadlines, retry policies, load-balancer integration.
3.3 Why RPC survives anyway
Even knowing transparency is a lie, RPC persists because "calling something" is a natural abstraction for humans.
// readable
user, err := userClient.GetUser(ctx, &pb.GetUserRequest{Id: 42})
// also readable, but you must reassemble the meaning yourself
resp, err := http.Get("https://api.example.com/v1/users/42")
if err != nil { /* transport-level failure */ }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { /* classify HTTP-level failure yourself */ }
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { /* ... */ }The difference is not just line count. In the first form, types, errors, deadlines, retries, and trace propagation are all the framework's responsibility. In the second, the application writes them every time, or forgets to. RPC's selling point is this automated discipline more than speed.
4. What REST really is β six constraints and what they buy
REST is a set of prohibitions, not features. Accept the prohibitions and infrastructure you did not write starts working on your behalf.
4.1 The six architectural constraints
Fielding's dissertation reverse-engineers why the Web scaled, and extracts these constraints.
| Constraint | Content | What you get |
|---|---|---|
| Clientβserver | Separate UI from data storage | Independent evolution |
| Stateless | No server-side session state between requests | Horizontal scale; route to any node |
| Cacheable | Responses declare their own cacheability | Fewer round trips; CDNs |
| Uniform interface | All resources obey the same operation vocabulary | Generic clients and generic intermediaries |
| Layered system | A client cannot tell whether it is talking to the origin | Freedom to insert proxies, LBs, gateways |
| Code on demand (optional) | The server can ship code that extends the client | Shipping JS. The one optional constraint |
Stateless is widely misread. It does not mean "no database". It means session state between requests must not live in server memory. That is what lets a load balancer send any request to any node.
4.2 The four sub-constraints of the uniform interface
The uniform interface is REST's core, and the part most often ignored.
- Identification of resources β a URI names a resource.
- Manipulation through representations β the client receives a representation (JSON, XML, HTML), not the resource itself, and sends one back to update it.
- Self-descriptive messages β each message carries enough information to be processed (
Content-Type,Cache-Control, β¦). - HATEOAS β hypermedia drives application state transitions.
The third has the largest practical effect. Because messages are self-descriptive, proxies, CDNs, WAFs, and browsers that know nothing about your domain can still do useful work. gRPC's opaque protobuf body gives up exactly this.
4.3 Why HATEOAS never caught on
In theory, the client only needs an entry point and follows links from there, so the server is free to reshape its URL space.
In practice few APIs adopted it, for three reasons:
- Clients hard-code anyway. Writing
/users/{id}is cheaper than writing a generic link-following client. - It fights typed client generation. When clients are generated from OpenAPI, URLs are fixed at generation time.
- Link relations still require domain knowledge. A machine cannot infer what
rel="cancel"does.
The exceptions that did succeed are pagination (next / prev links) and async jobs (202 Accepted plus a Location to poll). Both are cases where the client genuinely cannot construct the URL, so hypermedia earns its keep. A practical compromise is to use hypermedia only in cases like these.
If you do emit links, prefer an existing vocabulary over inventing your own: the Link header (RFC 8288), or HAL / JSON:API / Siren in the body.
4.4 Resource-oriented vs procedure-oriented β where do verbs go?
This is where teams collide most often. cancelOrder, sendEmail, reindexSearch are plainly procedures, not resources.
REST has three standard answers:
| Approach | Example | When |
|---|---|---|
| Update a state attribute | PATCH /orders/42 with status: "cancelled" | Simple transition, few side effects |
| Make the action a resource | POST /orders/42/cancellations | The action itself needs an ID, history, audit trail |
| Custom method | POST /v1/orders/42:cancel | Nothing above fits. Google's API design guide (AIP-136) |
The second approach turns a command into a noun, and that covers several needs at once. Rewriting "cancel the order" as "create a cancellation resource" gives you idempotency on retry (same ID, no duplicate), async processing (return 202 Accepted, finish later), and auditing (who cancelled, when).
On the gRPC side this dilemma does not exist: write CancelOrder(CancelOrderRequest) returns (CancelOrderResponse) and you're done. On expressiveness alone, RPC is the more direct fit.
4.5 Designing collections β pagination and partial responses
Resource-oriented or procedure-oriented, the endpoint that returns a list is usually the first to cause trouble. Its design matters regardless of your protocol choice.
Offset vs cursor| Aspect | Offset (?offset=100&limit=20) | Cursor (?page_token=xxx&page_size=20) |
|---|---|---|
| Ease of implementation | Easy (OFFSET / LIMIT) | Fiddly β you must encode the sort key |
| Deep-page performance | Poor; the database reads rows it throws away | Good; seeks directly via the index |
| Tolerance of inserts/deletes | Weak; rows shift, causing duplicates and gaps | Strong; it points at a value, not a position |
| "Jump to page N" | Possible | Not possible |
| Total count | Fits page-number UIs, but an exact COUNT(*) is still expensive at scale | Expensive (separate query, or an estimate) |
The bug "the same item appears again on page 2" is almost always caused by offset pagination. If the list can change while it is being read, make cursors the default.
Three rules for implementing cursors:
- Use a stable sort key.
created_atalone leaves ties unordered, so always append a unique tiebreaker, as in(created_at, id). - Keep the token opaque. Base64 is a useful URL-safe transport encoding, but encoding alone leaves the contents readable. State that the internal format is not part of the contract, and sign it, encrypt it, or hand back a server-side handle when that matters. Publish the structure and clients will start constructing tokens themselves, freezing your internal sort order forever.
- Embed the query parameters in the token. If a filter or sort order changes mid-pagination the results are garbage, so carry the criteria in the token and detect the mismatch.
Google's API design guide standardises this as page_size / page_token / next_page_token. The same shape works for REST and gRPC alike.
message ListUsersRequest {
int32 page_size = 1; // the server caps this; never trust the client's number
string page_token = 2; // opaque; echo back the previous next_page_token
}
message ListUsersResponse {
repeated User users = 1;
string next_page_token = 2; // empty string = last page
}Partial responses live at the same layer. "Returning every field is too heavy, but I don't want a bespoke endpoint" gets these answers:
| Style | Mechanism | Drawback |
|---|---|---|
| REST | ?fields=id,name, JSON:API sparse fieldsets | Non-standard syntax; it fragments the cache key (needs Vary-style thought) |
| gRPC | google.protobuf.FieldMask in the request | Decorative unless the server actually implements it |
| GraphQL | The selection set itself | Built into the core of the model |
FieldMask also carries update semantics, not just which fields a read returns. Adding update_mask to UpdateUser states explicitly that "fields not named in the mask are left alone"; think of it as expressing the PATCH vs PUT distinction in the type system. For AIP-134-style partial updates, FieldMask is the standard mechanism. And because proto3 implicit presence (Β§5.4) cannot distinguish "unset" from "zero", any field you need to update to a zero value also needs explicit presence, such as optional.
5. What actually happens on the wire
"gRPC is fast" breaks down into three separate things (header compression, multiplexing, and schema-driven metadata removal), and each can be evaluated on its own.
5.1 REST / JSON over HTTP/1.1
An ordinary request for one user:
GET /v1/users/42 HTTP/1.1
Host: api.example.com
Accept: application/json
Accept-Encoding: gzip, br
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
User-Agent: acme-client/1.4.2
Traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, max-age=60
ETag: "a1b2c3d4"
Vary: Accept-Encoding
Content-Length: 36
{"id":42,"name":"Ada","active":true}The body is 36 bytes; the request headers run to several hundred bytes, sometimes past a kilobyte, once a JWT, trace context, and user-agent are present. HTTP/1.1 has no header compression, so those hundreds of bytes are re-sent as uncompressed header fields on every request (TLS encrypts them; it does not compress them). For fine-grained service-to-service calls, headers routinely outweigh payloads.
HTTP/1.1 also handles one request at a time per connection (pipelining is effectively dead), so concurrency comes from opening more connections, and most browser implementations cap HTTP/1.1 connections at roughly six per origin.
5.2 gRPC over HTTP/2 β the frames
gRPC is a thin RPC protocol layered on HTTP/2. The same call looks like this:
Representative headers for this call (the required ones plus the commonly used optional ones):
:method = POST
:scheme = https
:path = /users.v1.UserService/GetUser
:authority = api.example.com
te = trailers
content-type = application/grpc+proto
grpc-timeout = 1S
grpc-encoding = identityOf these, only :method, :scheme, :path, content-type, and te: trailers are required by the spec; grpc-timeout and grpc-encoding are optional. Three design decisions are visible here:
:pathis the service and method name, in the fixed form/{package}.{Service}/{Method}. The URL names a procedure, not a resource.te: trailersis mandatory. gRPC returns final success/failure in trailers (headers that arrive after the body), and the spec requires this header so that intermediaries which cannot handle trailers are detected. Trailers are one reason gRPC assumes HTTP/2; multiplexing, flow control, and per-stream cancellation matter just as much.grpc-timeoutpropagates a deadline. The value is a positive integer of at most 8 digits plus a unit (Hhours,Mminutes,Sseconds,mmilliseconds,umicroseconds,nnanoseconds), so you write1Sor980m.1000msis not valid syntax. REST has no equivalent, and this is one of gRPC's biggest operational advantages (Β§7.5).
5.3 Why trailers are necessary
An HTTP status code is sent at the start of the response. But in server streaming you can send 200 OK, stream a hundred rows, and only then have the database fall over. At that point the status line cannot be rewritten.
gRPC solves this with trailers sent after the body:
grpc-status: 13
grpc-message: internal error while streaming rowsHTTP/1.1 trailers (with Transfer-Encoding: chunked) are too patchily implemented to rely on. Hence HTTP/2.
5.4 Protobuf encoding, one byte at a time
Protobuf's encoding is simple at its core: every field is a tag (field number << 3 | wire type) followed by a value.
| Wire type | Name | Used for |
|---|---|---|
| 0 | VARINT | int32/int64/uint/bool/enum |
| 1 | I64 | fixed64, sfixed64, double |
| 2 | LEN | string, bytes, embedded messages, packed repeated |
| 3 / 4 | SGROUP / EGROUP | deprecated (groups; historical) |
| 5 | I32 | fixed32, sfixed32, float |
Build a protobuf message byte by byte
Encode User{ id: 42, name: "Ada", active: true }, the GetUser response, as protobuf, then add the 5-byte gRPC prefix. The JSON panel shows the same value for comparison.
message 0 B / total 0 B
The same value as JSON
{"id":42,"name":"Ada","active":true}HTTP/2 frame headers (9 B each) and HPACK-compressed headers sit on top of this.
Protobuf carries no field names and no type names on the wire. Each field is just (field number, wire type, value). Leaving out this metadata works only because both ends share the schema (.proto); JSON, by contrast, spells out every field name.
Three practical consequences follow:
- Field numbers 1β15 have a one-byte tag (16β2047 take two). Give your hottest and most repeated fields low numbers.
- Under proto3 implicit presence, zero-valued fields never reach the wire.
0/false/""are encoded by omission, so you cannot distinguish "unset" from "explicitly zero". If you need that distinction, prefer proto3optionalfor explicit presence (enabled by default since protoc 3.15). Wrapper types such asgoogle.protobuf.Int32Valuehave receded to a fallback for older toolchains or JSONnullinteroperability. - Serialisation is not deterministic. Field ordering and unknown-field handling depend on implementation and version, so never hash protobuf bytes to build a signature or an idempotency key. This is a common and expensive mistake.
5.5 Actually counting the bytes
"Protobuf is smaller than JSON" is true, but how much smaller depends heavily on the shape of your data. Numeric-heavy records shrink a lot; long free text barely moves.
Same data, different wire formats
Exact byte counts for a user-list response encoded as JSON and as protobuf, computed by applying each format's encoding rules.
protobuf / JSON (minified)
47%
per record (minified JSON)
85.1 B
per record (protobuf)
40.0 B
first record
{"id":1,"name":"u1aaaaaa","email":"u1aaaaaa@example.com","active":true,"score":100}Caveat: this compares body bytes only. With gzip or brotli the gap narrows sharply, because JSON's repeated key names compress extremely well. If bandwidth is your only concern, turn on compression before reaching for protobuf. Compression does not remove the CPU cost of parsing or the guarantees a schema provides (types, presence, compatibility checks).
However, gzip or brotli closes most of the gap. Most of JSON's redundancy is repeated key names, the kind of repetition compressors handle well. If bandwidth is your only motivation, enabling Content-Encoding: br may buy nearly the same win at zero migration cost.
What compression does not remove:
- CPU cost β JSON parsing means string scanning, escape handling, and number conversion; length-prefixed binary is intrinsically cheaper.
- The guarantees a schema provides β types, presence, and compatibility checking have nothing to do with byte counts. Over the long term, they matter more.
5.6 The browser wall β gRPC-Web and Connect
Browser fetch / XMLHttpRequest cannot drive HTTP/2 frames directly: no reading trailers, no touching request flow control. So a browser cannot speak native gRPC.
Two answers exist:
- gRPC-Web β a variant that embeds trailers as a special frame at the end of the body. It needs either a server that speaks gRPC-Web directly or a translating proxy (for example Envoy's
grpc_webfilter), and it supports neither client streaming nor bidirectional streaming. - Connect β Buf's protocol.
POST /pkg.Service/Methodwithapplication/jsonorapplication/proto, so unary calls work with plain curl. One server can speak gRPC, gRPC-Web, and Connect simultaneously.
# With Connect you can debug without generated code or a proxy
curl -X POST https://api.example.com/users.v1.UserService/GetUser \
-H "Content-Type: application/json" \
-d '{"id": 42}'Whether you can hit an API with curl affects incident triage, support, and explaining things to external partners, so it has a large effect on operational cost.
6. Contracts β IDLs and schema evolution
6.1 A .proto can be the single source of truth
syntax = "proto3";
package users.v1;
message User {
int32 id = 1;
string name = 2;
string email = 3;
bool active = 4;
}
message GetUserRequest { int32 id = 1; }
message GetUserResponse { User user = 1; }
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}From this, client and server stubs are generated for Go, TypeScript, Java, and Python. The benefit is that a contract violation becomes a compile error. Remove a field on the server and the client's build fails, in CI, before anyone notices in production.
6.2 OpenAPI usually describes rather than drives
The same discipline is achievable with OpenAPI (3.1 aligns with JSON Schema 2020-12), and some teams do run schema-first. But in practice you see:
| Practice | What happens |
|---|---|
| Code-first (generate OpenAPI from implementation annotations) | The spec never lies, because it follows the code. But it cannot stop a breaking change |
| Schema-first (generate server/client from OpenAPI) | Equivalent discipline to gRPC. Toolchain maturity varies a lot by language |
| Hand-written docs | Almost certainly drifts from the implementation. The worst option |
A large fraction of the "gRPC or REST" debate is really a debate about whether a schema is enforced. Go schema-first with REST and add breaking-change detection in CI, and the gap narrows sharply.
6.3 Backward-compatibility rules
Protobuf's compatibility rules double as a textbook on API evolution.
| Change | Safe? | Notes |
|---|---|---|
| Add a field (new number) | β | Old clients preserve or ignore it as an unknown field |
| Remove a field | β οΈ | Always mark the number reserved. Reuse is catastrophic |
| Rename a field | β on the wire | But JSON mapping and generated code break |
| Change a field number | β | It becomes a different field |
| Change a type | β οΈ conditionally | int32/int64/uint32/uint64/bool are wire-compatible, but not automatically safe: out-of-range values truncate and change meaning. sint32/sint64 are compatible with each other but not with the other integer types |
Add optional | β οΈ | Presence semantics change |
| Add an enum value | β οΈ | Old clients receive an unknown value. Always make 0 an UNSPECIFIED sentinel |
Move an existing field into a oneof | β οΈ conditionally | Safe only for a single explicit-presence field (or an extension) moved into a new oneof; moving into an existing oneof, or moving multiple fields, is unsafe |
repeated β singular | β οΈ conditionally | Breaks around packed scalar encoding; message types are safer but the semantics still change |
message User {
reserved 3; // seal the removed field number
reserved "email"; // and the name
int32 id = 1;
string name = 2;
}ProtoJSON, protobuf's canonical JSON mapping, is a separate hazard. If you expose JSON through grpc-gateway, Connect, or protojson, "wire-compatible therefore safe" no longer holds.
- Renaming a field is harmless on the wire but changes the JSON key, so it is breaking. If you must rename, pin the old key with
json_name. - Enums serialise as their names by default, so renaming an enum value is a breaking change on the JSON side.
- Adding enum values is also risky in JSON. Because names are on the wire, an older parser can fail on a newly added name. Adding values is JSON-wire-safe only if every relevant client is configured to emit enums as integers; how unknown numeric values are then represented varies by language runtime.
The equivalent rules for JSON APIs:
- Adding is safe, unless the client throws on unknown fields (Jackson's
FAIL_ON_UNKNOWN_PROPERTIESis on by default). Configure tolerant reading explicitly. - Removing or retyping is breaking. Changing
"id": 42into"id": "42"fails silently in dynamically typed clients. - Adding a value to a string enum can also be breaking.
6.4 Versioning β three schools
| Approach | Example | Assessment |
|---|---|---|
| URL path | /v1/users, /v2/users | Most common. Highly visible, trivial to route. Coarse β the whole API moves at once |
| Media type | Accept: application/vnd.acme.user+json;version=2 | Purest REST. But CDN cache keys and WAFs handle it poorly |
| Field-level evolution | Add only, never remove | Protobuf's default. Removes the concept of a version entirely |
Putting users.v1 in the protobuf package name means: create a whole new package only when field-level evolution can no longer absorb the change. Functionally that is URL-path versioning; the advantage is that you need a new package far less often.
6.5 Be careful how widely you share the contract
Centralising every .proto into one company-wide repository looks ideal but produces tight coupling: one team edits common.proto and the whole company's builds are affected.
Practical guidance:
- Each service owns its
.proto. Keep shared types minimal and prefer the existinggoogle.protobuf.*/google.type.*. - Distribute through a schema registry (e.g. the Buf Schema Registry), not by copy-paste or a giant monorepo.
- Run breaking-change detection in CI (
buf breaking). This is the highest-ROI setting in gRPC operations.
Treat the registry as a governance mechanism, not a storage location. At minimum, decide these five things:
| Question | What to settle |
|---|---|
| Ownership | One team owns each module (β service) and is the only team that can push to it |
| Breaking-change policy | BSR-side breaking-change checks (available on Enterprise dedicated/private BSR instances) enforce FILE (the default) or WIRE_JSON when enabled; local buf breaking can also use PACKAGE or WIRE. Keep stable packages backward-compatible by default and ship unavoidable breaks as a new package (v2) |
| Review | Changes to .proto files that constitute a public API require a reviewer from the consuming side |
| Artifacts | Publish generated code from the registry, so teams aren't each running protoc at drifting versions |
| The escape hatch | Write down the procedure for pushing a breaking change anyway: who approves, how it's announced, when it lands |
Without that last row, the policy will eventually be ignored. A rule of "you may never break this" gets routed around the moment a break becomes necessary.
6.6 How to remove things β API lifecycle
Adding is easy; removing is hard. If you don't decide up front how things get deleted, the API only ever grows. Every layer offers a machine-readable way to signal deprecation.
| Layer | Mechanism | Effect |
|---|---|---|
| HTTP | Deprecation header (RFC 9745: when deprecation is or will be in effect; the date may be past or future) + Sunset header (RFC 8594, when it stops working) + Link with rel="deprecation" | Clients can automate logging and alerting |
| OpenAPI | deprecated: true | Generated clients emit warnings |
| Protobuf | option deprecated = true; on fields, methods, or messages | Generated code carries compiler warnings |
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse) {
option deprecated = true; // generated code emits a warning
}
}The most important part is measurement. Emit a metric for "who is still calling the old endpoint", labelled by caller identity (User-Agent, API key, client ID). Without it you can neither set a sunset date nor act on one. An API you cannot retire is one you end up maintaining indefinitely.
6.7 Enforcing the contract β what belongs in CI
A schema you don't check in CI is just documentation. Whether you find out you broke something in CI or in production accounts for almost all of a contract's value.
Checks come in three layers:
| Layer | What it catches | Example tooling |
|---|---|---|
| Syntax and style | Naming-convention violations, missing comments | buf lint, Spectral (OpenAPI) |
| Breaking changes | Removed fields, changed numbers, changed types | buf breaking (diff against the previous version), oasdiff |
| Behaviour | "The schema matches but the behaviour changed" | Consumer-driven contract tests (Pact et al.), compiling generated clients |
The first two layers are easy to automate and catch a lot for little effort. If you adopted .proto but never wired buf breaking into CI, you threw away half of gRPC's main benefit.
The third layer covers what a schema cannot: a schema only constrains shape. All of these are schema-clean and still break consumers:
- An optional field that was always populated stops being populated.
- The sort order changes, destroying pagination stability.
- An endpoint that returned an empty array starts returning
null. - An error code changes from
NOT_FOUNDtoPERMISSION_DENIED(correct as an information-leak fix, fatal to the client's branching).
Consumer-driven contract testing has the consumer write down its concrete expectations as tests, which the provider's CI then verifies. Pact is the best-known tool, but the idea matters more than the tool: a provider only has to preserve the parts that are actually used, not its entire surface.
A realistic minimum:
- Make
buf breaking(or an OpenAPI diff check) a required check in the provider's CI. - Keep representative consumer request/response examples as golden files in the provider's repository, so any change forces an explicit diff review.
- For your most important consumers, compile their generated clients in the provider's CI.
That alone stops most incidents before release.
7. Error design β where the real difference shows
If an error is only a string for humans to read, with no structure a machine can branch on, you cannot write retries or fallbacks.
7.1 The semantics of HTTP status codes
REST's strength is that the coarse classification of errors is built into the protocol, so intermediaries (LBs, CDNs, SDK retry layers) can act without understanding the payload.
| Code | Meaning | Retry |
|---|---|---|
| 400 | Malformed request | β Same result until fixed |
| 401 / 403 | Unauthenticated / forbidden | β (401 is retryable after refreshing a token) |
| 404 | No such resource | β |
| 409 | State conflict | β οΈ Depends on state |
| 412 | Precondition failed (If-Match mismatch) | β οΈ After re-reading |
| 422 | Syntactically valid, semantically unprocessable | β |
| 429 | Rate limited | β
Honour Retry-After |
| 500 | Server error | β οΈ Only for idempotent operations |
| 503 | Temporarily unavailable | β
Retry-After |
Two common abuses:
- Always returning 200 and signalling failure in a
{"error": ...}body. This disables retry logic in intermediaries and client SDKs. GraphQL's execution errors tend to arrive as 200 plus anerrorsarray as a consequence of its single-endpoint design, but an ordinary HTTP API has no reason to copy that. - Confusing 4xx and 5xx. Returning 500 for a validation error poisons your error-rate monitoring, triggers pointless retries, and corrupts your SLO. 4xx means the cause is on the caller's side; 5xx means it is on yours.
7.2 A standard error body β RFC 9457
There is a standard for structuring error details: RFC 9457, Problem Details for HTTP APIs (successor to RFC 7807).
{
"type": "https://api.example.com/problems/insufficient-funds",
"title": "Insufficient funds",
"status": 409,
"detail": "Account 42 has a balance of 30 USD; 50 USD required.",
"instance": "/accounts/42/transfers/8f2b",
"balance": 30,
"required": 50
}Return it as Content-Type: application/problem+json. type is a stable, machine-readable identifier and is what clients should branch on. detail is for humans. Never branch on it, because rewording will break callers.
7.3 The gRPC error model
gRPC has 17 fixed status codes (0β16), finer-grained than HTTP and designed to be a useful input into retry decisions.
| Code | Name | Typical meaning |
|---|---|---|
| 0 | OK | Success |
| 1 | CANCELLED | The caller cancelled |
| 2 | UNKNOWN | Unknown error, including errors converted from another error space |
| 3 | INVALID_ARGUMENT | Argument invalid regardless of state |
| 4 | DEADLINE_EXCEEDED | Deadline expired |
| 5 | NOT_FOUND | No such entity |
| 6 | ALREADY_EXISTS | Entity already exists |
| 7 | PERMISSION_DENIED | Not authorised |
| 8 | RESOURCE_EXHAUSTED | Quota or rate limit |
| 9 | FAILED_PRECONDITION | System state forbids it (retrying is pointless) |
| 10 | ABORTED | Aborted due to a conflict (retry at a higher level, e.g. read-modify-write) |
| 11 | OUT_OF_RANGE | Past the valid range |
| 12 | UNIMPLEMENTED | Not implemented |
| 13 | INTERNAL | Internal error |
| 14 | UNAVAILABLE | Transient unavailability (safe to retry) |
| 15 | DATA_LOSS | Unrecoverable data loss or corruption |
| 16 | UNAUTHENTICATED | Not authenticated |
Distinguishing 9 / 10 / 11 is the most valuable part of gRPC's error design. The official guidance: FAILED_PRECONDITION means the client should not retry until the system state is fixed; ABORTED means a higher-level retry (read-modify-write) will help; OUT_OF_RANGE may succeed once state changes. Those three distinctions alone let you write most retry logic mechanically. But the code alone does not settle whether an automatic retry is safe: for a non-idempotent operation, even UNAVAILABLE leaves open the possibility that the request landed (Β§7.4).
Structured detail rides in google.rpc.Status.details:
// google/rpc/error_details.proto provides the standard types:
// ErrorInfo, RetryInfo, QuotaFailure, BadRequest, PreconditionFailure, ...
message Status {
int32 code = 1;
string message = 2;
repeated google.protobuf.Any details = 3;
}RetryInfo says "retry after N seconds", QuotaFailure says which quota was hit, BadRequest says which field was invalid and why, all in machine-readable form. The REST equivalent is building the same thing yourself as RFC 9457 extension members.
Put them side by side and the distance is smaller than it looks. Returning a validation error:
// REST β RFC 9457 plus an "errors" extension member
{
"type": "https://api.example.com/problems/validation-error",
"title": "Your request is not valid.",
"status": 422,
"errors": [
{ "detail": "must be a positive integer", "pointer": "#/amount" },
{ "detail": "must be a valid ISO 4217 code", "pointer": "#/currency" }
]
}// gRPC β code = INVALID_ARGUMENT, with BadRequest in details
// (abridged; current FieldViolation also carries reason / localized_message)
message BadRequest {
message FieldViolation {
string field = 1; // "amount"
string description = 2; // "must be a positive integer"
}
repeated FieldViolation field_violations = 1;
}The structures are near-isomorphic. The difference is only whether the standard type ships with the platform or your team has to agree on one. RFC 9457 leaves extension members to each problem type's definition; even errors and pointer above are names from a fictional problem type in the RFC's own example. So your organisation must pick one shape and hold every API to it. Skip that step and you end up with errors in one service, violations in another, and fieldErrors in a third, at which point no shared client-side handler is possible.
7.4 Retries and idempotency β RPC's sore spot
Partial failure (Β§3.1) bites here: a timed-out request may or may not have executed.
REST solves part of this through HTTP method semantics (RFC 9110):
- Safe:
GET,HEAD,OPTIONS,TRACEβ no state change. - Idempotent: the above plus
PUT,DELETEβ repeating yields the same result. - Not defined as idempotent by default:
POST,PATCH. Individual resource semantics or an idempotency key can still make a specific operation safe to repeat.
So a generic HTTP client can treat idempotent methods as retry candidates when a communication failure prevented it from reading the response, while declining to auto-retry non-idempotent methods without extra knowledge. That knowledge is embedded in the protocol, so no application has to write it.
To make POST idempotent, use a client-generated key. The usual one is the Idempotency-Key header popularised by Stripe, currently an IETF HTTPAPI Working Group Internet-Draft rather than a published RFC:
POST /v1/payments HTTP/1.1
Idempotency-Key: 8f2b1c40-3d2e-4f9a-9b1e-6c7a0d5e2f13
Content-Type: application/json
{"amount": 5000, "currency": "JPY"}To make the key work, the server needs four disciplines:
- Key scope β uniqueness should be per (endpoint Γ authenticated principal Γ key). A globally unique namespace invites cross-tenant key collisions.
- Request fingerprinting β store a fingerprint of the payload and reject a same key, different body replay with
422 Unprocessable Content, as the Idempotency-Key draft specifies. Silently returning the old result is the worst outcome. - Response replay β persist the first response (status and body) and return exactly that for retries.
- TTL and in-flight state β expire after roughly 24 hours, and return
409 Conflictwhen a retry arrives while the original is still processing, so the work never runs twice.
gRPC cannot infer idempotency from a method name (CreateUser may or may not be idempotent), so you declare a retry policy in the service config:
{
"methodConfig": [{
"name": [{ "service": "users.v1.UserService", "method": "GetUser" }],
"retryPolicy": {
"maxAttempts": 4,
"initialBackoff": "0.1s",
"maxBackoff": "2s",
"backoffMultiplier": 2,
"retryableStatusCodes": ["UNAVAILABLE", "RESOURCE_EXHAUSTED"]
}
}]
}Always use exponential backoff with jitter and a hard cap on total attempts. Naive retries amplify a partial failure into a total one (a retry storm). gRPC's retry throttling (retryThrottling) exists precisely to damp that amplification. I covered the same amplification dynamics in the LLM load-balancing article.
7.5 Deadline propagation β built into gRPC, hand-built in REST
A gRPC client sets a deadline, and it propagates downstream automatically via grpc-timeout:
Client (deadline 1000ms)
ββ Gateway 980ms left β grpc-timeout: 980m
ββ OrderSvc 940ms left β grpc-timeout: 940m
ββ DB 900ms leftWithout deadlines, downstream services keep grinding on work the client abandoned long ago. During an incident that wasted work exhausts resources and delays recovery.
Propagation still depends on your code. It works only because you pass the incoming call context (Go's ctx, Java's Context) into every outgoing call. Handing work to a worker pool, rebuilding with context.Background(), or firing an async task all quietly break the deadline chain. The discipline of threading context through comes with the feature.
You can do the same in REST, but you have to build it: there is no standard header, so you define something like X-Request-Deadline and implement parsing, decrementing, and propagation in every service's middleware. The composition patterns from the middleware chain article apply directly.
8. Interaction patterns
REST has one shape, request/response, and covers everything else with workarounds. gRPC has four shapes as first-class types.
8.1 gRPC's four shapes
service Chat {
rpc Send(Msg) returns (Ack); // unary
rpc Subscribe(Filter) returns (stream Event); // server streaming
rpc Upload(stream Chunk) returns (Summary); // client streaming
rpc Session(stream Msg) returns (stream Msg); // bidirectional
}Each maps to one HTTP/2 stream, with ordering guarantees and flow control (WINDOW_UPDATE). Cancellation propagates immediately via RST_STREAM.
8.2 The REST equivalents
| Need | REST approach | Drawback |
|---|---|---|
| Continuous server β client notifications | SSE (Server-Sent Events, text/event-stream) | One-directional; occupies a connection on HTTP/1.1 |
| Bidirectional | WebSocket | Upgrades away from HTTP; no longer REST |
| Large uploads | Chunked transfer, multipart, append via PATCH | No standard for resume or progress |
| Async jobs | 202 Accepted + Location + polling | Polling interval vs. scale trade-off |
| Event notification | Webhooks | Delivery guarantees, signature verification, retries are all DIY |
LLM token streaming has, for now, settled on SSE in practice. OpenAI-compatible APIs and AG-UI use it directly. MCP is a slightly different case: its early "HTTP+SSE" transport was superseded by Streamable HTTP, where SSE is an optional response format a server may choose when returning multiple messages for one POST, not the transport itself. In both cases SSE won because it works straight from a browser and rides existing HTTP infrastructure.
8.3 Don't use streams as a message queue
gRPC bidirectional streams are not durable:
- Drop the connection and messages are gone.
- Restart the server and every stream dies.
- There is backpressure, but no persistent buffer.
- A slow client accumulates memory on the server.
If you want pub/sub, use Kafka, NATS, or a managed Pub/Sub. gRPC streams fit data that only matters while the connection is alive: live telemetry, progress of an in-flight job, tokens in an ongoing conversation.
8.4 The chatty-API problem
RPC-style designs accumulate fine-grained methods:
GetUser(42) β GetUserOrders(42) β GetOrderItems(o1), GetOrderItems(o2), ...At 1 ms per call, a hundred calls take 100 ms. The N+1 problem applies to RPC calls as well as database queries. Three remedies:
- Provide batch methods (
BatchGetOrders). This is the standard pattern in Google's API design guide. - Add an aggregation layer (BFF: Backends for Frontends) close to the client.
- Let the client choose (GraphQL, or REST's
?fields=/?expand=).
8.5 Flow control and backpressure
If you stream, you cannot avoid the fast producer / slow consumer problem. This is one of the few places where gRPC and REST genuinely differ in capability, not just style.
HTTP/2 maintains flow-control windows at two levels: per connection and per stream. The receiver returns WINDOW_UPDATE for what it has consumed, and the sender blocks once the window is exhausted. Backpressure is therefore built into the transport: a slow reader automatically throttles the writer.
Exactly how this plays out depends on the runtime and its buffer settings. In most gRPC implementations, if the application stops calling Recv / read, HTTP/2 window credit eventually stops flowing back and the sender feels the pressure. Two common ways to break this backpressure:
- Draining the receive loop straight into an internal queue. As far as the transport is concerned the bytes are consumed, so the window stays open, backpressure vanishes, and your application heap overflows instead. Bound the buffer and stop reading when it is full.
- Queueing non-blocking sends. Go's
stream.Sendblocks, but some language bindings expose an API that merely enqueues, with exactly the same result.
On the REST side, neither SSE nor the standard WebSocket API offers event-level application backpressure. WebSocket's bufferedAmount lets you observe the local send queue but does not throttle the producer for you (WebSocketStream does address backpressure, but it is not yet the common baseline). SSE carried over HTTP/2 does get transport-level flow control. Either way, the mechanisms that actually throttle events (subscriber-specified rates, acknowledgements, sampling, coalescing) are yours to design.
Never make "send as fast as you can" the default for a stream. The classic failure is one slow consumer eating the server's memory and degrading every other client. Pair a bounded buffer with an explicit disconnect policy: drop subscribers that fail to keep up for some interval.
9. Performance β decomposing "faster"
The wire format's share of latency varies with payload size and with how long the RTT and backend work take. With the default values of the lab in Β§9.1 (1.5 ms RTT, 8 ms of backend work, a 40 KB JSON payload), serialising and parsing take about 0.3 ms out of roughly 10 ms, or about 3% of the total. Before choosing a format, measure where the time goes.
9.1 Decompose the latency budget
Where does the latency budget go?
Decompose what "gRPC is NΓ faster than REST" benchmarks measure. Move the sliders to see what share of the total goes to serialisation.
reduction: 4.6%(10.14 ms β 9.68 ms)
Changing the format cuts total latency by less than 5%. Most of the time goes to terms the format does not change, such as RTT and backend work, so look at the database and the network path first.
Assumptions: serialisation throughput of 250 MB/s for JSON and 1200 MB/s for protobuf, counted twice (encode on the server, decode on the client). Both are order-of-magnitude figures that vary a lot by runtime and schema shape. Transfer is a naive bandwidth model that ignores congestion control, slow start, and HTTP/2 flow-control windows, so real measurements of large responses tend to show a bigger transfer slice than this. Header bytes (and HPACK compression) are not counted either.
Move the sliders and the pattern is clear: when RTT and backend work dominate, changing the format barely moves total latency. Benchmarks claiming "gRPC is 7Γ faster than REST" are usually measured under one of these conditions:
- Measured on localhost (RTT β 0), erasing the network term and inflating serialisation's share.
- An empty backend (echo), erasing the dominant term.
- Enormous payloads, making transfer and parsing dominant.
- No connection reuse on the HTTP/1.1 side, so handshake cost is counted.
- Many small requests with large headers, where HTTP/2's HPACK genuinely wins (this one is real).
That last case matters in practice: tens of thousands of small JWT-bearing requests per second between services really do benefit from header compression. But that is HTTP/2 winning, not gRPC. Run REST over HTTP/2 and you get the same benefit.
9.2 What actually helps, in order
Ordered by real-world impact:
- Make fewer calls (batching, caching, killing N+1). Orders of magnitude.
- Reuse connections (keep-alive, connection pools). A handshake costs several RTTs.
- Enable compression. Large gains for JSON.
- Use HTTP/2 or later. Multiplexing and header compression.
- Shrink payloads (stop returning fields nobody reads).
- Change the serialisation format. Only now.
Many projects start at step 6. It needs no measurement and is an easy technology decision to make, but it is also the lowest-ROI move on the list.
9.3 Throughput and the tail
Look at p99 rather than the mean and different factors surface:
- GC pressure: JSON parsing allocates large numbers of short-lived objects. Generated protobuf code writes into preallocated structs, allocating far less. Under high throughput this can show up in p99 (see the GC article).
- TCP-level head-of-line blocking in HTTP/2: a lost packet stalls every stream on that connection. HTTP/3 (QUIC) fixes this, though gRPC-over-HTTP/3 support varies by implementation.
- Connection count and congestion control: fewer connections means the congestion window ramps faster, but one stalled connection affects more requests.
9.4 What a fair benchmark requires
A "gRPC vs REST" benchmark is close to meaningless unless its conditions are stated. Check these both when you measure and when you read someone else's numbers:
- Same HTTP version on both sides? If it is gRPC on HTTP/2 versus REST on HTTP/1.1, you are mostly measuring the HTTP version.
- Are connections warm? Warm up first, and don't silently include a TLS handshake per request (or, if you do, say so).
- Is the runtime warm? JVM, .NET, and V8 are all slow for the first few thousand requests.
- Is compression matched? Comparing uncompressed JSON against protobuf is not a fair fight.
- Is the payload realistic? A 20-byte numeric message and a 200 KB response full of prose give opposite conclusions. Reproduce the real distribution.
- Is there any backend work? An echo server benchmark is a serialisation microbenchmark, not an API benchmark.
- Is the load generator saturated? If the client is pinned at 100% CPU, you are measuring the client.
- Are you reporting a distribution, not a mean? Publish p50, p99, p99.9. A mean-only comparison hides exactly the differences that show up in the tail.
- Have you avoided coordinated omission? A closed-loop generator that waits for each response before sending the next also stops sending when the server stalls, structurally under-reporting tail latency.
Numbers that fail this checklist tell you nothing beyond "that is what happened on that machine that day".
9.5 Compression β where it helps and where it backfires
Β§5.5 said "try compression first", but compression is not free either. The mechanisms also differ between REST and gRPC:
| Aspect | REST | gRPC |
|---|---|---|
| Negotiation | Accept-Encoding / Content-Encoding (RFC 9110) | grpc-accept-encoding / grpc-encoding |
| Granularity | The whole response | Per message (signalled by the compressed flag in the framing) |
| Mixing | Not possible (one encoding per response) | Compression can be turned on or off per message, but the algorithm itself is the RPC's grpc-encoding; you cannot switch algorithms freely within a stream |
| Headers | Uncompressed on HTTP/1.1; HPACK on HTTP/2 | HPACK (always, since it is HTTP/2) |
Because gRPC compresses per message, a stream can carry large payloads compressed with the RPC's selected encoding and small control messages uncompressed (compressed flag 0).
For small messages, compression makes things worse. gzip's minimum wrapper is a 10-byte header plus an 8-byte trailer, with deflate's own overhead on top, so a few dozen bytes of payload frequently grows when compressed, and you pay CPU and allocations for the privilege. For high-frequency small RPCs, compression off is faster. This is why many implementations expose a minimum-size threshold.
There is also a security consideration. If attacker-influenced content and a secret share a compression context, the compressed size leaks information about the secret (the CRIME/BREACH family of attacks). According to the BREACH authors, a secret such as a CSRF token can be recovered one character at a time when an HTTP-compressed response body contains both the secret and reflected user input. The attacker needs to inject guesses into the victim's requests and observe the length of the encrypted responses. The mitigations they list include disabling compression for such responses, separating secrets from user input, and masking secrets with a fresh random value on every request.
Reasonable defaults:
- Don't compress below ~1 KB (tune the threshold to your workload).
- Use brotli for large text-heavy responses (high levels for static content, lower levels when compressing on the fly).
- Never recompress already-compressed bytes (images, video, zip).
- For internal gRPC traffic, measure uncompressed first. With small payloads, off is frequently faster.
10. Caching β REST's biggest asymmetric advantage
HTTP caching is code you don't have to write. gRPC has no such layer.
10.1 What comes for free
Return the right Cache-Control and these layers cooperate automatically:
They work without knowing anything about your domain, purely from the response's self-description. Here the "self-descriptive messages" constraint (Β§4.2) pays off directly.
Key directives (RFC 9111):
Cache-Control: public, max-age=300, stale-while-revalidate=60
ETag: "v3-a1b2c3"
Vary: Accept-Encoding, Accept-Languagepublic/privateβ may a shared cache (CDN) store it? Marking a user-specific authenticated responsepublicis a data-leak bug.stale-while-revalidate(RFC 5861) β serve the stale copy instantly and refresh in the background. This helps tail latency considerably.Varyβ which request headers belong in the cache key. Omitting it is how language, encoding, and authorisation-dependent variants get cross-served.
10.2 Conditional requests and optimistic locking
Beyond saving bandwidth, ETag gives you conflict detection on write:
GET /v1/users/42
β 200 OK, ETag: "v3"
# if another client updated it first
PUT /v1/users/42
If-Match: "v3"
β 412 Precondition FailedThat is textbook optimistic concurrency control. The gRPC equivalent is carrying a version field in the request message and implementing the comparison yourself. REST does it with one header.
10.3 The reality on the gRPC side
gRPC uses POST, its body is opaque binary, and its responses carry no standard cache semantics. Therefore:
- CDNs are effectively unusable.
- Intermediate caches do nothing.
- Caching must be built inside the client or the service (Redis, etc.).
This asymmetry is the technical basis for "REST wins for public, read-heavy, cacheable APIs". Conversely, for internal write-heavy traffic that must always be fresh, the caching advantage evaporates and so does gRPC's disadvantage.
11. Operations β where teams actually get hurt
Most of gRPC's adoption cost is not the protocol; it is everything around it.
11.1 The load-balancing trap
This is the most common trap in gRPC adoption.
gRPC holds one long-lived HTTP/2 connection and multiplexes all requests over it. An L4 (TCP) load balancer balances connections, so once a connection is established every request on that channel keeps going to the same backend. When most traffic comes from a small number of clients, scaling to ten replicas can still leave the load on one. With many clients, per-connection spreading softens the symptom.
Three remedies:
| Approach | How | Caveat |
|---|---|---|
| L7 proxy | Envoy / linkerd / NGINX terminates HTTP/2 and balances per request | Adds a hop |
| Client-side LB | The client connects to all backends and picks (gRPC round_robin, xDS) | Requires service discovery |
| Bounded connection lifetime | Server-side MAX_CONNECTION_AGE (plus grace) forces periodic reconnect | A complement, not a fix on its own |
If you pick client-side load balancing, the implementation details decide the behaviour:
- Name resolution. gRPC's default resolver is DNS, and it neither caches forever nor re-resolves aggressively. A normal Kubernetes
Service(ClusterIP) returns one virtual IP, so the client sees exactly one backend and you are back to L4 balancing. Use a headless Service (clusterIP: None) so every Pod IP is returned. - The load-balancing policy. The default is
pick_first, which connects to one backend and keeps using it. You must explicitly selectround_robinto spread load. "I enabled client-side LB and it still skews" is almost always this. - xDS. The Envoy-derived control-plane protocol lets you push load-balancing policy (weighting, zone affinity, outlier detection) to gRPC clients without a sidecar. It is the option when you don't want a full service mesh.
- Keepalive vs connection age.
KEEPALIVE_TIMEdetects dead connections;MAX_CONNECTION_AGEredistributes live ones.
The same cause explains why traffic doesn't flow to newly added backends: existing connections never move on their own. Without connection rotation such as MAX_CONNECTION_AGE, existing clients can take a very long time to start sending traffic to new replicas.
REST over HTTP/1.1 hides this because connections are shorter-lived and get redistributed often. But run REST over HTTP/2 and the same problem appears. This is a property of long-lived multiplexed connections, not of gRPC.
11.2 Observability and debugging
| Task | REST | gRPC |
|---|---|---|
| One-off manual call | curl | grpcurl / buf curl (with reflection enabled) |
| Reading packets | Plain text (after TLS termination) | Needs the schema to decode protobuf |
| Logging bodies | Directly readable | Must be decoded and structured |
| Browser DevTools | Visible as-is | Hard to read even with gRPC-Web |
| Existing APM / WAF | Supported out of the box | Often limited support |
Enabling server reflection lets grpcurl fetch the schema at runtime, which makes debugging far easier (the canonical service today is grpc.reflection.v1.ServerReflection; many servers and tools still also expose the deprecated grpc.reflection.v1alpha.ServerReflection for compatibility). But consider disabling it on externally exposed production endpoints, since it exposes your entire API surface. Enabled internally, disabled at the edge, is the usual settlement.
Naming conventions for metrics and traces shape operations just as much as the protocol choice does. Get them wrong and the observability stack itself breaks.
- Label with the template, never the actual ID.
/v1/users/{id}, not/v1/users/8f2b.... For gRPC,pkg.Service/Methodis already a stable name. Putting real IDs in labels detonates time-series cardinality and takes the metrics backend down. This accident is more common on the REST side; gRPC normally stays inside the finite set of declared methods, but unknown-method paths and custom labels can still manufacture high cardinality if you let them. - Normalise the status label. HTTP gives you both
2xx/4xx/5xxbuckets and exact codes; gRPC gives you a status code (rpc.response.status_codein current OpenTelemetry,grpc_codein older gRPC metrics). If both coexist during a migration, make them additive on a dashboard (the Β§13.4 mapping guidance pays off again here). - Follow the OpenTelemetry semantic conventions. Don't rename stable HTTP attributes such as
http.route, or RPC attributes such asrpc.system.nameandrpc.method, into something bespoke.rpc.systemandrpc.serviceare deprecated, andrpc.methodnow carries the fully qualified service/method name. (The RPC conventions are still a Release Candidate, so expect to track them.) Whether off-the-shelf dashboards and alerts work at all is decided here. - Thread the same trace context through every signal. Accept or mint W3C Trace Context (
traceparent) at the edge; writeTraceId/SpanIdinto logs, let metric exemplars carrytrace_id/span_id, and record spans in the trace. Whether you can go from "this request was slow" to that single request's logs in one click has a large effect on how long incident response takes.
For trace propagation, both carry W3C Trace Context (traceparent / tracestate): REST in HTTP headers, gRPC in metadata, which is the same HTTP/2 header mechanism underneath, so OpenTelemetry propagators work in both. Two gRPC constraints to remember: metadata keys must be lowercase, and only keys with a -bin suffix may carry binary values.
11.3 Authentication and authorisation β orthogonal to the protocol
Design authn/authz almost independently of the protocol choice. "We use gRPC, therefore this auth scheme" is a decision you will regret. That said, they surface differently in REST and gRPC.
- Authentication (who are you): gRPC separates channel credentials (TLS / mTLS) from call credentials (tokens) as distinct types, so "mTLS for the connection, an OAuth token per call" expresses naturally. REST typically funnels everything through one
Authorizationheader, with mTLS managed separately as infrastructure configuration. - Authorisation (what may you do): here there is a real structural difference.
| Granularity | REST | gRPC |
|---|---|---|
| Per endpoint | Gateway policy over URL patterns (/admin/*) | Interceptor or proxy policy per /pkg.Service/Method |
| Per object | "Does this order belong to this user?" (decided in the application) | Also in the application |
| Per field | Strip during response shaping | Same (pairs neatly with FieldMask) |
Under either protocol, object-level authorisation needs application and domain context. You can centralise the policy decision in OPA, Cedar, or a Zanzibar-style system, but supplying the subject/action/object attributes and enforcing the answer stays with the application. Keep the gateway as a coarse doorman (reject unauthenticated traffic, block admin APIs) and put fine-grained decisions next to the domain logic.
For service-to-service authentication there are three options:
- mTLS β mutual TLS proves identity at the transport layer. Combined with a workload-identity system such as SPIFFE/SPIRE, certificate issuance and rotation become automatic. This is one of the main reasons teams adopt a service mesh.
- Bearer tokens (JWT) β carried at the application layer. Easy to debug and survives gateway hops. But anyone holding the token can use it, so keep lifetimes short and always validate
aud(the intended service). - Both β mTLS says which workload; the JWT says which end user it is acting for. This is the standard answer at scale.
An easily overlooked trap is forwarding an end user's access token verbatim to downstream services. Don't. Propagate a constrained delegation instead: a short-lived token whose aud is the downstream service and whose scope is the minimum required. If the original token has a broad audience, or a downstream service validates aud weakly, raw forwarding lets that service reuse authority outside the intended delegation path: a confused-deputy-style failure. OAuth 2.0 Token Exchange (RFC 8693), or an internal STS that mints equivalent down-scoped tokens, is the correct approach.
WAFs and API gateways can inspect JSON bodies, but cannot inspect protobuf without the descriptors. In regulated industries, that inspection can be a hard requirement.
11.4 The browser safety boundary β CORS and CSRF
Public APIs trip here long before they trip on wire-format performance. The moment a browser can call you, you are in same-origin-policy territory.
| Style | Preflight | CSRF exposure |
|---|---|---|
REST (application/json) | Almost always (a JSON content type is not a "simple request") | Needs mitigation under cookie auth: SameSite plus a token |
| REST (form-encoded / simple requests) | None | Dangerous: simple requests are sent without a preflight |
GraphQL (POST + JSON) | Yes | Especially risky with cookie auth plus GET queries enabled |
| gRPC-Web / Connect | Yes (custom headers) | Safer by default, since Authorization-header auth doesn't rely on cookies |
Two practical rules:
- Authenticate with an
Authorizationheader rather than cookies and most CSRF disappears structurally, because browsers do not attach it automatically. If you must use cookies, pairSameSite=Lax/Strictwith a CSRF token. Access-Control-Allow-Origin: *cannot be combined with credentials. If you returnAccess-Control-Allow-Credentials: true, you must enumerate origins explicitly. Loosening this is a direct data-exposure path.
11.5 Middleboxes
Old proxies, corporate firewalls, and some cloud load balancers still misbehave with HTTP/2, especially cleartext HTTP/2 (h2c) and long-lived streams. The further a call travels outside your control, the more the "it works everywhere" property of REST/JSON is worth.
12. The third options β GraphQL, Connect, tRPC
The choice is not binary. The real option space is organised by who decides what gets fetched.
12.1 GraphQL β the client decides the shape
GraphQL (Facebook, published 2015) sends a query language to a single endpoint, letting the client declare away both over-fetching and under-fetching.
query {
user(id: 42) {
name
orders(last: 3) { id total items { sku } }
}
}In Richardson's terms, typical GraphQL APIs sit close to Level 0 because they centre on a single endpoint (though the GraphQL-over-HTTP specification does permit GET for queries, with mutations using POST). The trade is explicit:
| You gain | You lose |
|---|---|
| Client-driven fetching; aggregation in one round trip | HTTP caching (effectively a single URL plus POST) |
| A strong type system and introspection | Error semantics expressible in the status code alone; execution errors mostly arrive as 200 plus an errors array, while malformed requests and method errors do use 4xx |
| Front-end autonomy | You must control query complexity (depth limits, cost analysis) |
| Schema composition (Federation) across owners | Server-side N+1 mitigation (DataLoader) is effectively mandatory |
The caching problem has a mitigation: persisted queries. You register the query, then call GET /graphql?sha256Hash=..., which restores CDN caching. Treat it as mandatory if you deploy GraphQL at the edge.
GraphQL's sweet spot is the BFF: many clients (web, iOS, Android) needing different shapes, over many backend services.
12.2 Connect β "gRPC you can curl"
Buf's Connect keeps gRPC's type safety while restoring HTTP affinity:
- One server speaks gRPC, gRPC-Web, and Connect simultaneously.
- Unary calls are
POST /pkg.Service/Methodwithapplication/json, so curl works directly. - Browsers work without a proxy.
- Your
.protofiles and code generation are unchanged.
It directly addresses a common hesitation: "I want gRPC but I'm worried about browsers and debuggability." For a new internal API, it deserves consideration before plain gRPC.
12.3 tRPC β the single-language optimum
tRPC is TypeScript-only and has no code generation and no IDL. Types are inferred from the server's router definition and imported directly by the client.
// server
const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.number() }))
.query(({ input }) => db.user.find(input.id)),
});
export type AppRouter = typeof appRouter;
// client β fully typed with nothing generated
const user = await trpc.getUser.query({ id: 42 });The preconditions are strict: client and server in one TypeScript monorepo, deployed together. Meet them and the developer experience is excellent; violate them (another language, independent deploys, external exposure) and it collapses.
12.4 Putting them side by side
The symbols in the table below are my assessment, based on the preceding sections: β means supported and β means not supported. The HTTP-caching column asks whether standard HTTP caches such as browsers and CDNs can store responses; β³ means that only some calls, sent as GET, can be cached (Connect methods marked side-effect-free and called with GET, tRPC queries, GraphQL persisted queries). The browser column asks whether a browser can call the API without a translating proxy.
| Style | Who decides the shape | Schema | HTTP caching | Browser | Streaming |
|---|---|---|---|---|---|
| REST (Level 2) | Server | OpenAPI (optional) | β full | β | SSE / WebSocket |
| gRPC | Server | .proto (required) | β | Needs a proxy | β four shapes |
| Connect | Server | .proto (required) | β³ limited | β | β |
| GraphQL | Client | SDL (required) | β³ partial via persisted queries | β | Subscriptions |
| tRPC | Server | TS types (implicit) | β³ queries only | β | β |
| JSON-RPC 2.0 | Server | none (optional) | β | β | Transport-dependent |
The last row, JSON-RPC 2.0, is what MCP (Model Context Protocol) is built on: one of the newest protocols as of 2026 picked a thoroughly 1990s-flavoured RPC, most likely because it is easy for both humans and LLMs to read and write, and the implementation bar is extremely low. I read this as a sign that the reason to choose RPC has shifted from performance to readability and ease of implementation.
13. Making the call
The deciding factor is less technical superiority than the nature of the boundary: who is on each side.
13.1 A decision tree
13.2 Think in terms of the boundary
Simplified further, the whole decision collapses to one question: can one organisation change both sides of the contract at the same time?
| Boundary | Change synchronisation | Recommendation | Why |
|---|---|---|---|
| Services inside one team | Deploy together | gRPC / Connect / tRPC | Maximum benefit from typed contracts; breaking changes are cheap |
| Across internal teams | Requires coordination | gRPC / Connect + schema registry | Breaking-change detection earns its keep |
| Internal β external partner | Months of lead time | REST + OpenAPI | You don't control their stack |
| Public API | Can never break | REST + OpenAPI + explicit versioning | Universality, caching, visibility |
| Browser β your server | Usually deployed together | REST / GraphQL / Connect | Browser constraints dominate |
Conway's law applies here. The shape of an API should reflect the relationship between the teams it separates. Where coupling is acceptable, strong typed contracts win; where decoupling is mandatory, "hard to break and readable by anyone" wins.
13.3 Mixing is the realistic answer
You don't have to choose one. The most common production shape:
Annotate the .proto with HTTP mappings and one definition can serve both gRPC and REST:
import "google/api/annotations.proto";
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse) {
option (google.api.http) = { get: "/v1/users/{id}" };
}
}Feed that to grpc-gateway and you get a REST/JSON reverse proxy. But don't over-trust it. A generated REST surface tends to be a literal transcription of protobuf methods: URL design, error representation, and cache headers are all afterthoughts, which is not the same as a good Level 2 REST API. If it's going public, design it deliberately instead of shipping the generated output.
13.4 How to migrate β never all at once
When moving an existing REST API to gRPC (or the reverse), big-bang migrations almost always fail. The workable procedure is the strangler-fig pattern.
- Put a facade at the boundary. URLs and response shapes visible to clients do not change by a single byte. That invariance is the precondition for everything else.
- Write new endpoints in the new style first. Starting with additions is safer and easier to review than rewriting what exists.
- Move one endpoint at a time. After each move, compare the old and new paths' responses on mirrored production traffic (shadow / dark traffic).
- Meter calls to the old path. Delete when it reaches zero. Β§6.6's "you cannot retire what you cannot measure" applies directly.
- Use Connect as the waypoint. One server speaking gRPC, gRPC-Web, and Connect simultaneously lets you move the server first and migrate clients afterwards, one at a time. That is the cheapest way to run both worlds during the transition.
The most commonly missed piece is the error-semantics mapping. The canonical mapping pairs NOT_FOUND with 404, FAILED_PRECONDITION with 400, and ABORTED with 409; if you are deliberately preserving a legacy REST convention, write down the exceptions explicitly. Settle the mapping on the first day of the migration; otherwise client retry behaviour changes silently mid-migration.
14. Anti-patterns
Things that have caused real incidents, or easily could:
- Always returning HTTP 200. Disables every retry and circuit breaker in intermediaries and client SDKs.
- Returning 500 for validation errors. Poisons error-rate monitoring, breaks SLOs, triggers useless retries.
- Side effects on
GET. Prefetchers, crawlers, and browser reloads will fire them for you. Cache-Control: publicon authenticated responses. The CDN will serve one user's data to another. This has happened more than once, in production.- Forgetting
Vary. Language- and encoding-specific responses get cross-served. - Raw gRPC as a public API. You force a toolchain on your consumers and lose curl-based support.
- Reusing a protobuf field number. Old clients' values get reinterpreted with new meaning, which leads directly to data corruption. Always
reserved. - Hashing protobuf bytes for an idempotency key. Serialisation is non-deterministic, so identical messages can hash differently.
- gRPC calls without a deadline. They wait forever and exhaust resources. Give every call a deadline.
- gRPC behind an L4 load balancer. Skewed load, and scaling out does nothing (Β§11.1).
- Using gRPC streams as a message queue. A disconnect loses messages.
- Hand-writing OpenAPI after the fact. Drifts from the implementation and produces the worst possible state: documentation that exists and lies.
- Collection endpoints without pagination. Protocol-independent; it will fall over eventually.
- One company-wide
.protorepository. A single change stops everyone's build. - Not checking schemas in CI. Without
buf breakingor an OpenAPI diff check, the contract reverts to being documentation. - Draining streams into unbounded queues. You have not solved backpressure; you have moved it from the transport into your application heap.
- Compressing everything unconditionally. For small RPCs and already-compressed bytes you burn CPU and can even end up larger on the wire.
15. Wrapping up
- RPC abstracts "the call". Over forty years it evolved by giving up transparency; modern gRPC makes deadlines, types, and streaming explicit. What you buy is expressiveness plus automated discipline.
- REST is a set of constraints. Honour the uniform interface and statelessness and infrastructure you did not write (CDNs, proxies, browsers, generic clients) starts working for you.
- The performance gap decomposes into three parts. Header compression and multiplexing come from HTTP/2; metadata removal comes from the schema. REST over HTTP/2 already collects the first two.
- The real differences show up in errors, caching, and contracts. gRPC is stronger on deadline propagation and breaking-change detection; REST is stronger on caching and visibility.
- The decision follows the boundary. If both sides change together, favour the strong typed contract. If they can't, favour what is hard to break and readable by anyone.
- Choosing a style without designing load balancing, observability, authn/authz, and the browser boundary does not survive production. The load-balancing trap comes from long-lived multiplexed HTTP/2 connections, so REST over HTTP/2 hits it too (Β§11.1).
15.1 A pre-production checklist
Once the style is chosen, these are the items teams actually miss.
If you chose gRPC / Connect- Every call sets a deadline (Β§7.5)
- Load balancing is per request, or
round_robinplus a headless Service (Β§11.1) -
MAX_CONNECTION_AGEis set, and you have verified that scale-out actually shifts traffic (Β§11.1) -
buf breakingruns in CI (Β§6.7) - Reflection is enabled internally and disabled at the public edge (Β§11.2)
- The retry policy has exponential backoff, jitter, and throttling (Β§7.4)
- Stream receive buffers are bounded, with a policy for dropping slow subscribers (Β§8.5)
- Metric labels are low-cardinality (Β§11.2)
- OpenAPI is the single source of truth (schema-first, or generated from the implementation) and diff-checked in CI (Β§6.2 / Β§6.7)
- Collection endpoints use cursor pagination (Β§4.5)
- Errors use RFC 9457 with a stable
typeidentifier (Β§7.2) - Retryable non-idempotent operations accept
Idempotency-Keyand implement the four server disciplines (Β§7.4) -
Cache-ControlandVaryare correct, and no authenticated response is markedpublic(Β§10.1) - 4xx and 5xx are not confused (Β§7.1)
- Deprecations carry
Deprecation/Sunsetplus a usage metric (Β§6.6) - Timeouts are set and propagated at every hop (Β§7.5)
The choice is not a one-time, irreversible decision. gRPC internally with a REST gateway at the edge, a GraphQL BFF in front: these are ordinary architectures.
If your team can name the constraints it accepted at each boundary, for example "gRPC here because we want contract enforcement and deadline propagation, and we don't need caching", the decision is probably right.
References
- A. D. Birrell and B. J. Nelson, "Implementing Remote Procedure Calls", ACM Transactions on Computer Systems, 2(1):39β59, 1984.
- R. T. Fielding, Architectural Styles and the Design of Network-based Software Architectures, Ph.D. dissertation, UC Irvine, 2000. Online
- J. Waldo, G. Wyant, A. Wollrath, S. Kendall, "A Note on Distributed Computing", Sun Microsystems Laboratories TR-94-29, 1994.
- R. T. Fielding, "REST APIs must be hypertext-driven", 2008.
- M. Fowler, "Richardson Maturity Model", 2010.
- RFC 9110 β HTTP Semantics / RFC 9111 β HTTP Caching / RFC 9112 β HTTP/1.1
- RFC 9113 β HTTP/2 / RFC 9114 β HTTP/3 / RFC 7541 β HPACK
- RFC 9457 β Problem Details for HTTP APIs
- RFC 5531 β ONC RPC v2 / RFC 4506 β XDR
- gRPC over HTTP/2 protocol specification
- gRPC status codes and their use
- Protocol Buffers β Encoding / Proto Best Practices
- Google Cloud API Design Guide / AIP-136 Custom methods / AIP-158 Pagination / AIP-134 Standard methods: Update (FieldMask)
- Connect protocol β Buf
- GraphQL specification / GraphQL over HTTP
- RFC 9745 β The Deprecation HTTP Response Header Field / RFC 8594 β The Sunset HTTP Header Field
- The Idempotency-Key HTTP Header Field β IETF HTTPAPI WG Internet-Draft (not an RFC)
- gRPC Compression / gRPC Load Balancing
- Buf β breaking change detection / BSR breaking change checks
- OpenTelemetry β HTTP semantic conventions / RPC semantic conventions
- W3C Trace Context / OpenTelemetry Metrics Data Model β Exemplars
- RFC 8693 β OAuth 2.0 Token Exchange
- SPIFFE Overview
- Pact β consumer-driven contract testing
- RFC 8288 β Web Linking / RFC 5861 β HTTP Cache-Control Extensions for Stale Content
- Y. Gluck, N. Harris, A. Prado, "BREACH: Reviving the CRIME Attack", 2013. breachattack.com
- tRPC β Response Caching / Connect β Get Requests and Caching