Hotshard
Networking

gRPC & Protobuf

Two services agree on a contract, then talk in compact binary over one connection.

You have a few hundred services calling each other all day. Right now they speak REST over HTTP with JSON bodies, and it mostly works. But nothing stops one team from renaming a field and quietly breaking three callers, every request re-sends the same field names as text, and a long-running call has no clean way to stream results back. gRPC is Google's answer to exactly this in-house problem: write the service down as a typed contract, generate the client and server code from it, and send messages as tight binary over a single HTTP/2 connection. This page builds up why each of those choices exists, then puts REST, gRPC, and GraphQL side by side so you can say which one fits which job.

~14 min read

The problem: services that talk a lot, and drift apart#

TL;DRthe 30-second version
  • Write the service in a `.proto` file: the methods it offers and the shape of every message. This is the contract.
  • Run a code generator over the `.proto`. It spits out a client stub and a server skeleton in your language, so a remote call looks like a normal function call.
  • Messages travel as Protobuf: a compact binary format that sends a small field number instead of the field's name, and packs integers into as few bytes as possible.
  • The bytes ride on HTTP/2, so many calls share one connection at once, and a call can stream messages in either direction.
  • Every call carries a deadline. When it runs out, the call is cancelled everywhere and returns a numeric status code. That's gRPC.

Picture a backend split into hundreds of small services. A checkout service calls a pricing service, which calls an inventory service, which calls three more. They talk constantly, and the calls are on the hot path, so every millisecond and every wasted byte is multiplied by a very large number of requests.

The common way to wire this up is REST over HTTP with JSON β€” the same style covered in the HTTP topic (/http/sim). It's a fine default, and for a public API it's often the right one. But inside a busy backend it starts to chafe in three specific ways.

  • No enforced contract. The pricing service expects a field called `unit_price`. A caller sends `unitPrice`. Nothing checks this until it fails in production, because JSON has no shared, machine-checked definition of what a valid request looks like.
  • JSON is bulky and slow. Every request re-sends the field names as text ("customer_id", "quantity") and every number as ASCII digits. The receiver then parses that text back into real values. Across millions of internal calls, that's a lot of bytes on the wire and CPU spent parsing.
  • No real streaming. A call that returns a large or open-ended result β€” a live feed of price changes, a big export β€” has to be faked with polling or long-held connections, because plain request/response gives you exactly one response per request.

Google hit this at scale long before most: since around 2001 it ran an internal framework called Stubby to connect its services. gRPC, open-sourced in 2015, is the public rebuild of that idea. So the design goal is concrete β€” fast, typed, streaming calls between services you control β€” and every piece below is there to serve it.

Start with a contract, generate the code#

The first move is to stop describing the service in prose and a wiki page, and write it down in one file that both sides read. That file is written in an IDL β€” an interface definition language, a small language whose only job is to describe an interface. gRPC's IDL is Protocol Buffers, usually shortened to Protobuf.

Here's a slimmed-down version of a real one: the API that etcd β€” a distributed key-value store used by Kubernetes β€” exposes to its clients. It has a couple of methods and the messages they exchange.

service KV {
  rpc Put   (PutRequest)   returns (PutResponse);
  rpc Range (RangeRequest) returns (RangeResponse);
}

message PutRequest {
  bytes key   = 1;   // field number 1
  bytes value = 2;   // field number 2
}
A .proto file: the service's methods and its message shapes

Two things are being declared. A service is the set of methods a server offers β€” here `Put` to store a key and `Range` to read keys back. Each method is marked `rpc`, which stands for remote procedure call: a call to another machine written to look like an ordinary local function. That's the RPC in gRPC. A message is the shape of a request or response, a list of typed fields. And notice each field has a small number next to it. That number, not the name, is what actually goes on the wire β€” hold onto that, it's the key to the next section.

Now the payoff. You run a compiler over the `.proto` file: `protoc`, plus a plugin for your language. It reads the contract and generates code on both sides. For the client it generates a stub: an object with a real `Put(...)` method you call like any local function, which underneath serializes your message, sends it, and hands you back the reply. For the server it generates a skeleton: an interface you implement with the actual logic, wired to receive and decode incoming calls. You write neither the networking nor the parsing.

kv.protothe contract, written once
compile the contract
protoc + language pluginthe code generator
emit matching code for each side
client stub + server skeletongenerated code, one per language
One contract, generated into matching client and server code
This is what "contract-first" buys youBecause both sides are generated from the same file, they can't disagree about the shape of a message β€” a mismatch is a compile error, not a 2am production surprise. The client team in Go and the server team in Java generate from the identical `.proto`, so their types line up by construction. The contract is the single source of truth, and the code is downstream of it.

Protobuf on the wire: numbers, not names#

Now to why the bytes are small. Serializing that `PutRequest` as JSON would look like `{"key":"foo","value":"bar"}` β€” 27 characters, and the field names "key" and "value" are sent in full on every single request. Protobuf's insight is that both sides already have the contract, so the field names are redundant. It sends the field number instead.

Each field on the wire is a tiny tag followed by the value. The tag is a single number that packs two things: the field's number (which field is this?) and its wire type (how is the value encoded β€” is it an integer, or a length-prefixed chunk of bytes?). The receiver reads the tag, learns "this is field 1, and it's a length-delimited value," looks up field 1 in its copy of the contract, and knows it's the `key`. The name never travelled.

Integers get the same frugal treatment through varint encoding β€” "variable-length integer." Instead of always spending a fixed 4 or 8 bytes on a number, a varint uses one byte for small numbers and adds bytes only as the number grows. It works by taking 7 bits of the number per byte and using the 8th bit as a flag that means "there's another byte coming." So a small value like `5` fits in one byte; you only pay for the size you actually use.

PredictA message has one integer field, `count`, with field number 5, set to the value 1. As JSON that's `{"count":1}` β€” 11 bytes. Roughly how many bytes is the Protobuf encoding, and where did the savings come from?

Hint: One byte for the tag (field number + type), then the value as a varint. How big is the varint for 1?

Two bytes. One byte is the tag encoding field number 5 and the wire type for an integer; the second byte is the varint for the value 1. That's it β€” 11 bytes down to 2. The savings come from two places: the field name "count" is never sent (the contract already maps number 5 to that field), and the number 1 is a single varint byte instead of the ASCII text "1". Across millions of calls, sending numbers instead of names, and packed integers instead of text, is where Protobuf's speed and size advantage over JSON comes from.

There's a second, quieter payoff to identifying fields by number: it's what lets the contract change safely over time. A new field just gets a new, unused number. An old client that has never heard of that number reads the message, hits a tag it doesn't recognize, and simply skips it β€” no crash, no coordinated deploy. That property is called schema evolution, and it's the deeper reason services can be updated one at a time.

Go deeperGo deeper: the wire format, byte by byte

The tag is itself a varint, computed as `(field_number << 3) | wire_type`. The bottom 3 bits are the wire type; everything above is the field number. Three bits give eight possible wire types, of which four are in normal use:

  • 0 β€” varint-style integers (the packed integers from above).
  • 1 β€” a fixed 8-byte value, like a double.
  • 2 β€” length-delimited data: strings, raw bytes, and nested messages. On the wire it's a length varint followed by that many bytes.
  • 5 β€” a fixed 4-byte value.

This is why field numbers 1 through 15 are worth guarding. Their tag β€” field number and wire type together β€” fits in a single byte, while field 16 and up spill into a second tag byte. So the Protobuf style guide says to spend the low numbers on your most frequent fields and leave the rest for the occasional ones.

key = "foo"0x0Atag: field 1, type 20x03length: 3foo3 bytes
value = "bar"0x12tag: field 2, type 20x03length: 3bar3 bytes
PutRequest { key: "foo", value: "bar" } as 10 bytes
The rules that keep old and new clients compatibleSchema evolution has a short list of rules. Never reuse or renumber an existing field β€” the number is its identity forever. Adding a new field is always safe (old readers skip it). Removing one is safe as long as you never hand its number to a different field later, which is why the removed number gets marked `reserved`. Follow those and a client compiled last year keeps working against a server deployed today, in both directions.

Riding HTTP/2: one connection, four call shapes#

A compact message still needs a way across the network, and gRPC doesn't invent one β€” it runs on HTTP/2, the transport covered in the HTTP/2 & HTTP/3 topic (/http2-3/sim). That choice matters for one reason above all: HTTP/2 multiplexes. Many independent calls share a single connection and travel at the same time, interleaved as separate streams, instead of each waiting for the last to finish. For a service making thousands of concurrent calls, opening one connection instead of thousands is a large saving on its own.

The connection itself is normally wrapped in TLS, so the traffic is encrypted and each side can verify who it's talking to β€” the handshake from the TLS topic (/tls/sim). In gRPC terms this secured connection is called a channel, and your stubs make their calls over it.

Because HTTP/2 streams can stay open and carry many messages, a gRPC method isn't limited to one-request-one-response. It can take one of four shapes, chosen in the `.proto` with the `stream` keyword. The etcd contract from earlier shows one: `Put` is a plain single call. Its `Watch` method (not shown in that snippet) is a long-lived two-way stream that pushes changes as they happen.

Call typeClient sendsServer sendsExample
Unaryone messageone messagePut a key, get an ack
Server-streamingone messagea stream of messagesOne query, many rows back
Client-streaminga stream of messagesone messageUpload chunks, get one summary
Bidirectionala stream of messagesa stream of messagesetcd Watch: send filters, receive live events

Unary is the ordinary function-call shape and covers most methods. The streaming shapes are what plain REST can only fake β€” and they cost nothing extra to set up, because they're just an HTTP/2 stream left open with more messages flowing through it.

Clientwatches keysetcdKV store
One stream stays open; both sides send whenever they have something.
watch key "config/"
event: config/db = ...changed
event: config/cache = ...changed
also watch "flags/"
event: flags/beta = on
A bidirectional stream: etcd Watch on one HTTP/2 stream

The call itself: deadlines, cancellation, and status#

A remote call can hang β€” the far service is slow, or wedged, or the network dropped. gRPC bakes the answer into every call: a deadline. When you make a call you say "I'll wait at most 300 milliseconds." If the reply hasn't come by then, the call is abandoned and returns an error, and the client stops waiting.

The clever part is that the deadline travels with the call. Say the checkout service sets a 300 ms deadline on a call to pricing, and pricing has to call inventory to answer. gRPC forwards the remaining time β€” if 100 ms have already gone, inventory is told it has 200 ms left. It's sent as a remaining duration rather than a wall-clock time, so the two machines' clocks don't have to agree. When the deadline passes, every call in that chain is cancelled together, so no service keeps grinding on work whose answer nobody is waiting for anymore. That's deadline propagation, and it's how gRPC stops one slow service from tying up an entire call tree.

Every call ends with a status code: a small number saying how it went. `0` is OK. The rest name the failure in a way both sides agree on, so a caller can react to the kind of error, not parse a message. The common ones are worth knowing by name.

CodeNameMeans
0OKSuccess
3INVALID_ARGUMENTThe client sent a bad request
4DEADLINE_EXCEEDEDThe deadline passed before the reply came
5NOT_FOUNDThe thing asked for doesn't exist
14UNAVAILABLEThe server is down or unreachable β€” usually safe to retry

These codes carry real weight in practice. `UNAVAILABLE` tells a client the call is worth retrying (the request may never have landed), while `INVALID_ARGUMENT` says the opposite β€” retrying the same bad request just fails again. Getting retry logic right hangs on reading these codes correctly.

Where it breaks
  • Browsers can't speak it directly. gRPC leans on low-level HTTP/2 features a browser doesn't hand to JavaScript β€” raw frames (the individual framing units a stream is chopped into) and trailers (a bit of metadata sent after the response body, where gRPC puts the final status). So a web page can't call a gRPC server straight. You run gRPC-Web through a proxy (commonly Envoy) that translates the browser-friendly variant into real gRPC behind it. This is the single biggest reason gRPC stays a backend-to-backend tool.
  • The payload is opaque. Because the bytes are binary, you can't read a request in the browser network tab or with curl the way you can with JSON. Debugging needs the `.proto` and a tool that can decode against it (grpcurl, or Kreya-style clients), which is a real change in workflow.
  • A deadline that's too tight causes DEADLINE_EXCEEDED storms. Set the timeout below what the call normally takes under load and every request fails at the deadline β€” turning a slowness problem into a total outage. Deadlines must be sized against real p99 latency (the time 99% of calls finish within), not a hopeful guess.
  • Load balancing is trickier than with REST. gRPC keeps long-lived connections and multiplexes many calls over each, so a normal connection-level load balancer sends every call from one client to the same backend. Spreading load evenly needs request-aware balancing β€” called L7, or application-layer, because it looks inside each request β€” that understands HTTP/2 streams.
gRPC-Web is a subset, reached through a proxygRPC-Web exists so browser code can use the same contracts and generated stubs. But it can't do everything full gRPC can β€” client-streaming and bidirectional streaming are limited, because the browser can't send a live outbound stream of frames. The proxy in front translates what the browser can express into full gRPC for the backend, and back. If a web client is a first-class consumer of your API, that constraint should shape the design.
Isn't gRPC just a faster REST?

It overlaps but isn't a drop-in swap. gRPC gives you a typed contract, generated stubs, binary encoding, and real streaming β€” at the cost of not being human-readable and not working from a browser without a proxy. REST is easier to call from anything, cache with standard HTTP tooling, and read by eye. Faster on the wire, yes; universally better, no.

Do I have to use Protobuf with gRPC?

In practice, almost always. gRPC is designed around Protobuf and it's the default everywhere, so treat them as a pair. The framework technically allows other message formats, but choosing gRPC effectively means choosing Protobuf, and the two are usually learned together.

What happens if the client and server .proto files drift apart?

It depends on how they drift. Add a field on one side and the other simply ignores the unknown number β€” safe, by design. But reuse or renumber an existing field and you get silent corruption: the reader decodes bytes into the wrong field. That's why the cardinal rule is never to change or reuse a field number, only add new ones.

Why can't my React app call the gRPC service directly?

Because browsers don't give JavaScript access to the low-level HTTP/2 features (raw frames and trailers) that gRPC needs. You either put a gRPC-Web proxy in front to translate, or expose a small REST or GraphQL gateway for the browser and keep gRPC for service-to-service traffic behind it.

QuizA team's gRPC service works perfectly between backends, but their new web dashboard can't call it at all. What's the most likely fix?

  1. Switch the messages from Protobuf to JSON
  2. Put a gRPC-Web proxy (e.g. Envoy) in front, or expose a REST/GraphQL gateway for the browser
  3. Increase the deadline on every call
  4. Add more field numbers to the .proto
Show answer

Put a gRPC-Web proxy (e.g. Envoy) in front, or expose a REST/GraphQL gateway for the browser β€” Browsers can't speak native gRPC because they don't expose the raw HTTP/2 frames and trailers it depends on. The standard fixes are a gRPC-Web proxy (such as Envoy) that translates a browser-friendly variant into full gRPC, or a thin REST/GraphQL gateway for the browser while services keep using gRPC among themselves. Changing the encoding, the deadline, or the field numbers addresses none of that β€” the blocker is the browser's transport, not the payload.

When to reach for gRPC (and when not to)

gRPC is at its best in the environment it was built for: many services you own, talking to each other a lot, in a mix of languages, where speed and a firm contract matter more than being readable by a human or callable from a browser. It's the wrong reach the moment your main consumer is a browser, a third-party developer, or anyone who benefits from a plain, inspectable API.

  • Strong fit: internal service-to-service traffic on the hot path, polyglot backends (a Go service and a Java service sharing one contract), and anything that needs real streaming β€” live feeds, long uploads, chatty two-way calls.
  • Weak fit: public APIs where third parties must integrate easily, browser-facing endpoints (without accepting the gRPC-Web proxy), and simple CRUD services β€” plain create, read, update, delete over records β€” where REST's readability and ubiquitous tooling outweigh gRPC's speed.
  • Watch out: the binary payload changes how you debug and monitor, and the long-lived multiplexed connections need L7-aware load balancing. Both are solvable, but they're real operational costs to plan for, not afterthoughts.
REST vs gRPC vs GraphQL

These three are the usual candidates for how services and clients talk, and they're not really competitors so much as answers to different questions. REST is resource-oriented over plain HTTP. gRPC is method calls over HTTP/2 with a binary contract. GraphQL is a query language that lets the client ask for exactly the fields it wants from one endpoint. Here's how they line up on the axes that actually decide it.

RESTgRPCGraphQL
ShapeResources + HTTP verbsTyped method calls (RPC)One endpoint, client-chosen query
PayloadJSON (text, readable)Protobuf (binary, compact)JSON (text, readable)
ContractOptional (OpenAPI, add-on)Required (.proto), generated stubsRequired (schema), typed
Browser supportNative, trivialNeeds gRPC-Web + proxyNative (it's HTTP + JSON)
StreamingAwkward (polling or server-push)First-class, four call typesSubscriptions (add-on)
Best atPublic APIs, simple CRUD, cachingInternal, fast, polyglot, streamingClient-driven data, avoiding over-fetching

The honest one-liner: REST for public and browser-facing APIs where readability and HTTP caching win; gRPC for internal, high-volume, streaming service-to-service traffic where a firm contract and small fast messages win; GraphQL when many different clients each need a different slice of the data and you want to stop over-fetching. Many real systems use two of them β€” gRPC between services, and REST or GraphQL at the edge for browsers β€” rather than picking one for everything.

PredictYou're building a mobile app's public API and an internal fleet of microservices behind it. Which protocol goes where, and why not one for both?

Hint: One consumer is a browser/app you don't fully control; the other is services you own and that talk constantly.

Use REST or GraphQL at the edge for the app, and gRPC between the internal services. The edge faces clients you don't control and possibly a browser, so it needs the readability, easy tooling, and native browser support of REST or GraphQL β€” gRPC there would force a gRPC-Web proxy for little gain. The internal fleet is the opposite case: services you own, in mixed languages, calling each other on the hot path, where gRPC's typed contract, binary speed, and streaming pay off directly. Forcing one protocol everywhere means either slow, contract-loose internal calls (all REST) or a painful browser story (all gRPC). Matching each tier to the protocol whose strengths it needs is the strong answer.

In the wild, and why it fits
  • Google runs gRPC (and its ancestor Stubby) as the backbone of communication between its internal services β€” the original workload the design was shaped by: enormous request volume across many languages, where wire efficiency and a shared contract are not luxuries.
  • etcd exposes its entire v3 client API as gRPC β€” the KV service (Put/Range) and the bidirectional Watch stream used throughout this page are real methods. Because Kubernetes stores its state in etcd, gRPC sits under one of the most-deployed systems in the industry.
  • Netflix moved its internal service-to-service traffic to gRPC after outgrowing an HTTP/1.1 stack, for the generated cross-language stubs and the connection efficiency at their request volume. Square, Dropbox, and Spotify adopted it for the same service-mesh reasons.
  • CockroachDB and TiKV use gRPC for node-to-node traffic inside the database β€” the internal chatter of a distributed system is exactly the polyglot, high-volume, contract-heavy pattern gRPC targets.
The pattern behind every fitEvery one of these is internal, high-volume, and multi-language, with machines β€” not humans β€” on both ends. That's the environment gRPC was built for, and the reason you rarely see it as a public, browser-facing API. When the consumer is another service you own, its trade-offs are almost all upside; when the consumer is a person or a browser, they flip.
In an interview

The trap here is treating gRPC as "faster REST" and stopping. The strong answer explains the chain of choices β€” contract, encoding, transport β€” and then names honestly where it doesn't fit. Lead with the shape and let the reasons follow.

  • State the spine in one breath: a `.proto` contract, code-generated stubs, Protobuf binary messages, riding multiplexed HTTP/2, with deadlines and status codes on every call.
  • Explain why it's small: field numbers instead of names, varint-packed integers β€” the contract is shared, so the names don't need to travel. Bonus points for the schema-evolution payoff (add fields freely, never reuse a number).
  • Name the four call types (unary, server-, client-, and bidirectional streaming) and give a real streaming use case, not just the definitions.
  • Do the REST-vs-gRPC-vs-GraphQL comparison as a matching problem: internal/fast/streaming β†’ gRPC; public/browser/readable β†’ REST; client-chosen fields β†’ GraphQL. Say that real systems mix them.
  • Volunteer the limits before you're asked: no native browser support (needs gRPC-Web + a proxy), opaque binary payloads that change debugging, and L7 load balancing for the long-lived connections. Naming the downsides is what signals real experience.
PredictAn interviewer asks: "Your team put gRPC on a public-facing endpoint and third-party developers are complaining. What went wrong, and what would you do?"

Hint: Who is the consumer, and what does gRPC ask of them that REST doesn't?

gRPC was pointed at the wrong consumer. A public endpoint is used by outside developers and often browsers, and gRPC asks a lot of them: a binary payload they can't read or debug with ordinary HTTP tools, a `.proto` and code-generation step to integrate, and no native browser support without a gRPC-Web proxy. Those are all fine for internal services you control, and all friction for third parties. The fix is to expose a REST or GraphQL API at the public edge β€” readable, browser-native, easy to integrate β€” and keep gRPC for the internal service-to-service traffic behind it, where its speed and contracts actually pay off. The underlying lesson, which is what the interviewer wants, is matching the protocol to the consumer rather than standardizing on one everywhere.

References & further reading
References

Feedback on this topic β†’