gRPC and gRPC-Web
A fast way for services to call each other's functions using compact binary messages and a shared contract; browsers reach it through gRPC-Web or the Connect protocol.
The problem gRPC solves
Picture a food delivery app. When a user opens an order, the page asks one backend for the order. That backend then asks the menu service for the dish names, the courier service for the driver, the pricing service for the total, and the maps service for the arrival time. One click in the browser becomes five or more calls inside the data center.
Now multiply that by a few million users. The company runs hundreds of services, written in Go, Java, Kotlin, and Python, and they call each other millions of times a second. Each call is small, but there are so many that small costs add up to real money and real latency.
What goes wrong with JSON over plain HTTP
Most teams start with JSON over HTTP, the style covered in the REST API design lesson. It works, and for a public API it is still the right default. Inside a busy backend, though, four problems show up.
- Wordy messages. Every JSON object repeats its field names as text. A list of 500 orders sends the word
"deliveryEta"500 times. Numbers travel as text too, so1734567890is 10 bytes instead of 5. - Slow parsing. Turning text into objects costs CPU. At millions of calls a second, servers spend a real share of their time reading and writing JSON.
- No shared contract. The courier team renames
driverIdtocourierId. Nothing stops the deploy. The order service readsundefined, and the bug shows up in production as a blank driver card. - Hand-written clients. Every team writes its own request code in its own language: URLs, headers, retries, error parsing. Ten teams means ten slightly different clients for the same API.
None of these is fatal alone. Together, at scale, they are why Google built an internal system called Stubby to handle its service calls, and in 2015 released a public successor: gRPC. Today gRPC is a Cloud Native Computing Foundation project used by companies such as Netflix, Square, Dropbox, and Cisco.
Three ideas in one package
gRPC is not one trick. It bundles three ideas, and it helps to keep them apart because the browser story treats each one differently.
- A contract language. You describe services, methods, and messages in a
.protofile. That file is the single source of truth for both sides. - A binary format. Messages are encoded with Protocol Buffers (protobuf), a compact binary format that is smaller and faster to read than JSON.
- A transport. Calls run over HTTP/2, which lets many calls share one connection and lets either side send a stream of messages.
Here is the catch that makes this a frontend topic. The first two ideas work fine in a browser. The third one does not: a web page cannot speak the gRPC transport directly. That gap is why gRPC-Web and the Connect protocol exist, and it is a favourite interview follow-up.
Where a frontend engineer meets it
You will meet gRPC in one of three places. Most often it sits behind your API: the browser calls REST or GraphQL, and that layer calls gRPC services. Sometimes your backend for frontend (BFF, a small server built for one client app) is the gRPC client, and you write or review its code. And in a growing number of products, the browser calls gRPC services itself through gRPC-Web or Connect, with a TypeScript client generated from the same .proto files the services use.
The mental model: a contract with numbered boxes
Imagine two offices that send each other the same form all day. Instead of writing "Customer name: Asha" in full, both offices keep the same printed form, so the sender writes "box 2: Asha". The letter is much shorter and faster to read. The catch: without the printed form, the letter means nothing.
That is gRPC in one picture. The .proto file is the printed form, and a protobuf message is the short letter that says "box 1 is 1234, box 2 is Asha". In the binary format, the field names stay at home in the .proto file; only the box numbers travel.
Keep this picture: almost every rule in this lesson follows from it.
A real .proto file
Here is a small but realistic contract for the delivery app's order service. It defines messages (the shapes of data) and a service (the functions you can call). RPC means remote procedure call: code calls what looks like a local function, and the work runs on another machine.
syntax = "proto3";
package orders.v1;
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_PLACED = 1;
ORDER_STATUS_COOKING = 2;
ORDER_STATUS_ON_THE_WAY = 3;
ORDER_STATUS_DELIVERED = 4;
}
message Order {
int64 id = 1;
OrderStatus status = 2;
int32 total_cents = 3;
string restaurant_name = 4;
optional int32 eta_minutes = 5;
reserved 6; // old "courier_phone", removed
}
message GetOrderRequest { int64 id = 1; }
message ListOrdersRequest { int32 page_size = 1; string page_token = 2; }
message ListOrdersResponse { repeated Order orders = 1; string next_page_token = 2; }
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse);
rpc WatchOrder(GetOrderRequest) returns (stream Order);
}
Read it top to bottom. package orders.v1 puts a version in the name, so a breaking redesign can live beside the old one as orders.v2. The enum starts with an UNSPECIFIED value at zero, because zero is what a reader sees when the field was never set.
repeated means a list. stream on the return type means WatchOrder sends many Order messages over time instead of one.
Thinking in actions, not resources
REST thinks in nouns and HTTP verbs: GET /orders/42, DELETE /orders/42. gRPC thinks in actions: GetOrder, CancelOrder, WatchOrder. Neither is better. Actions fit well when an operation is not a simple create, read, update, or delete, such as RecalculateEta or ReassignCourier, which REST designs often squeeze into awkward URLs.
On the wire, every method becomes an HTTP POST to a path built from the package, service, and method: /orders.v1.OrderService/GetOrder. That one detail matters later, because a POST is not cached by browsers or CDNs the way a GET is.
The rules for changing a contract safely
A web app and a mobile app may run old code for weeks after the backend changes. Protobuf was designed so old and new code can still talk. The rules come straight from the numbered-boxes picture.
- Rename with care. In the binary format names never travel, so renaming
restaurant_nametostore_namedoes not change a single byte. The JSON format, used by Connect's JSON mode and by transcoding, does send names, so there a rename breaks old clients. It also changes generated code, so your TypeScript will need a small edit. - Add new fields with new numbers. Old readers skip numbers they don't know, and new readers see the default value when an old writer leaves the field out.
- Never reuse or renumber a shipped field. If box 6 used to be a phone number and now holds a tip amount, an old app reads the tip as a phone number. Mark retired numbers with
reservedso nobody reuses them by mistake. - Don't change a field's type in a way that changes its wire encoding, such as
stringtoint32. Some changes are compatible on the wire (int32toint64), but they can still break generated code or truncate values. - Removing a field is safe on the wire but not for meaning: a client that relied on it will now see the default value, which may look like real data.
Check your understanding · Exercise 1
The order service has shipped the Order message above. Which of these changes are safe for web and mobile clients that still run the old generated code? Select all that apply.
Protobuf on the wire, byte by byte
To trust the size and speed claims, it helps to see the bytes once. Take a tiny User message with three fields: int32 id = 1, string name = 2, and bool online = 3. We encode the user { id: 1234, name: "Asha", online: true }.
As compact JSON, {"id":1234,"name":"Asha","online":true} is 39 bytes. As protobuf it is 11 bytes: 08 d2 09 12 04 41 73 68 61 18 01 in hexadecimal. Here is where each byte comes from.
Tags: box number plus wire type
Each field starts with a tag: the field number shifted left by three bits, plus a 3-bit wire type that tells the reader how to find the end of the value. There are only a few wire types: 0 for varints (integers, bools, enums), 2 for length-delimited data (strings, bytes, nested messages, packed lists), and 1 or 5 for fixed 8-byte or 4-byte numbers.
08is field 1, wire type 0:(1 << 3) | 0 = 8.12is field 2, wire type 2:(2 << 3) | 2 = 18, which is0x12.18is field 3, wire type 0:(3 << 3) | 0 = 24, which is0x18.
Field numbers 1 to 15 fit in a one-byte tag. Numbers 16 to 2047 need two bytes. That is why careful teams give numbers 1 to 15 to the fields that are set most often.
Varints: small numbers take few bytes
Integers use a varint, short for variable-length integer. Each byte holds 7 bits of the number, lowest bits first, and its top bit says "another byte follows". So 1 to 127 take one byte, up to 16,383 take two, and so on.
1234 in binary is 100 1101 0010. The low 7 bits are 101 0010 (0x52); with the "more" bit set that becomes 0xd2. The remaining bits are 1001 (9), so the next byte is 0x09. Two bytes, where JSON spent four characters on the digits and seven more on "id":.
The string follows the length-delimited rule: tag 12, then the length 04, then the four UTF-8 bytes of Asha: 41 73 68 61. The bool is tag 18 and value 01. A false bool, a 0 number, and an empty string are not written at all in proto3, which is why defaults cost nothing.
12 means field 2, length-delimited, so the next byte is a length. No field names appear anywhere.Try it: encode a message yourself
Change the values below and watch both encodings. Notice three things: the field names cost bytes only in JSON, an id of 127 versus 128 changes the protobuf size by one byte, and turning online off removes that field from the protobuf entirely.
JSON, 39 bytes: {"id":1234,"name":"Asha","online":true}
Protobuf, 11 bytes: 08 d2 09 12 04 41 73 68 61 18 01
id:08 d2 09, tag 08 (field 1, varint), then 2 bytes for 1234.name:12 04 41 73 68 61, tag 12 (field 2, length-delimited), length 4, then the UTF-8 bytes.online:18 01, tag 18 (field 3, varint), then 01 for true.
Protobuf is 3.5 times smaller here.
How much smaller, really?
One message tells you little. Real responses are lists, and real responses are compressed. So here is an illustrative comparison we computed for this lesson: a ListOrdersResponse with 100 orders, each with the five Order fields above plus a creation timestamp, encoded both ways and then gzipped. Your data will give different numbers; the shape of the result is what matters.
| Encoding | One order | 100 orders | 100 orders, gzipped |
|---|---|---|---|
| JSON (protobuf's JSON mapping, camelCase names) | 127 bytes | 12.8 KB | 1.6 KB |
| Protobuf binary | 32 bytes | 3.3 KB | 1.4 KB |
Before compression, protobuf is about 4 times smaller. After gzip, the gap almost closes, because gzip is very good at removing repeated field names. This is the honest version of the size story: for a browser that already compresses JSON, the byte savings of protobuf are modest.
The bigger wins are elsewhere. Protobuf skips the text parsing, so encoding and decoding cost less CPU, which matters for a server doing it millions of times a second. Small messages, like one stream update, often go out uncompressed, and there protobuf's 4 times matters. And the typed contract catches mistakes at build time, which is worth more than any byte count.
Check your understanding · Exercise 2
Which statements about protobuf encoding are true? Select all that apply.
How a gRPC call travels over HTTP/2
Now the transport. gRPC is defined on top of HTTP/2, the version of HTTP that splits each connection into many independent streams, one per request, and sends data as small binary frames. If HTTP/2 is new to you, the HTTP versions lesson covers it; here you only need three frame types: HEADERS, DATA, and a second HEADERS at the end called trailers.
One unary call, step by step
A unary call is the simple kind: one request message, one response message. Here is GetOrder({ id: 42 }) as it really travels.
- The client opens a stream and sends a
HEADERSframe::method POST,:path /orders.v1.OrderService/GetOrder,content-type: application/grpc,te: trailers, and oftengrpc-timeout: 2Sfor a 2-second deadline. - It sends a
DATAframe with the message, wrapped in a 5-byte prefix: 1 byte that says whether the message is compressed, then 4 bytes with the message length. This length-prefixed message is how gRPC knows where one message ends and the next begins. - The server replies with
HEADERS::status 200andcontent-type: application/grpc. - It sends the response message in
DATA, with the same 5-byte prefix. - It ends with trailers:
grpc-status: 0and, on errors,grpc-messagewith a description. Only now does the client know whether the call succeeded.
200 before the server even knows how the call ends. The real result, grpc-status, arrives last, in the trailers.Notice what step 5 means. The HTTP status is 200 even when the call fails. The real result lives in grpc-status, which can only be sent at the end, because a streaming call may send 1,000 messages before something goes wrong.
Putting the status after the body is a sensible design for streams. It is also the single detail that breaks browsers, as the next chapter shows.
HEADERS :status 200
content-type: application/grpc
DATA 00 00 00 00 20 (32 bytes of Order)
HEADERS grpc-status: 0 (trailers, end of stream)
# The same call when the order does not exist:
HEADERS :status 200
content-type: application/grpc
grpc-status: 5
grpc-message: order 42 not found
Status codes and deadlines
gRPC has its own 17 status codes instead of HTTP's. A frontend engineer mostly meets a handful, and maps them to UI states the same way you would map HTTP errors.
| Code | Name | Rough HTTP twin | What the UI does |
|---|---|---|---|
| 0 | OK | 200 | Show the data |
| 3 | INVALID_ARGUMENT | 400 | Show field errors; don't retry |
| 4 | DEADLINE_EXCEEDED | 504 | Retry if the call is safe to repeat |
| 5 | NOT_FOUND | 404 | Show an empty or missing state |
| 7 | PERMISSION_DENIED | 403 | Explain the user can't do this |
| 14 | UNAVAILABLE | 503 | Retry with backoff; the server is briefly down |
| 16 | UNAUTHENTICATED | 401 | Refresh the token or sign in again |
A deadline is how long the caller is willing to wait, sent as grpc-timeout. When a server makes its own calls with the request's context, gRPC libraries pass the remaining time on, so a 2-second deadline set by the order page also limits the courier and maps calls behind it. When time runs out, every call in the chain is cancelled instead of finishing work nobody will read.
Four kinds of calls
Because HTTP/2 lets both sides send DATA frames whenever they want, gRPC offers four kinds of methods. You choose one by writing stream before the request type, the response type, or both.
| Kind | In the .proto | Client sends | Server sends | Example |
|---|---|---|---|---|
| Unary | rpc GetOrder(Req) returns (Order) | One message | One message | Load an order |
| Server streaming | returns (stream Order) | One message | Many, over time | Live order status until delivery |
| Client streaming | rpc Upload(stream Chunk) | Many, over time | One at the end | Upload GPS points, get a summary |
| Bidirectional | (stream Msg) returns (stream Msg) | Many | Many, at the same time | Chat, or a courier app syncing both ways |
Each stream is still one HTTP/2 stream on a shared connection. A courier app can hold a bidirectional location stream, a server stream of new jobs, and dozens of unary calls on a single TCP connection, with no extra handshakes.
Check your understanding · Exercise 3
Which call kind fits each method best? Select all that apply.
Think first
A teammate adds a monitoring alert: "page us when more than 1% of responses from the order service are not HTTP 2xx". The service speaks native gRPC. What will the alert miss?
Almost every failure. gRPC responses use HTTP 200 even when the call fails; the real result is in the grpc-status trailer. A service returning UNAVAILABLE or INTERNAL for half its calls would still look perfectly healthy to an HTTP-status alert.
The fix is to alert on grpc-status (most proxies and gRPC libraries export it as a metric), or, for Connect unary calls, on the real HTTP status that Connect maps each error code to. The same trap applies to browser error logging: log the gRPC code, not the HTTP status.
Why a browser can't make a native gRPC call
A web page has no socket of its own. Every request goes through fetch (or the older XMLHttpRequest), and those APIs show the page a simplified view of HTTP: a method, a URL, headers, a body, a status, response headers, and a response body. That view is the same whether the browser used HTTP/1.1, 2, or 3. gRPC needs things that live below that view.
te: trailers, and HTTP/2 framing, sits below the line that fetch draws around the page.Gap 1: the page can't read trailers
gRPC puts the call's result, grpc-status, in the trailers. fetch gives you response.headers and response.body, and nothing for headers that arrive after the body. A trailers API was once drafted in the Fetch standard, never shipped in any browser, and was removed. So a page can read every byte of a gRPC response and still not know whether the call succeeded.
It gets worse on the way out. The gRPC spec requires clients to send te: trailers, which tells servers and proxies that trailers are welcome, and some gRPC servers reject calls without it. TE is on the Fetch standard's list of forbidden request headers: the browser silently drops it if a page tries to set it.
Gap 2: the page can't choose HTTP/2
gRPC is defined only over HTTP/2. A browser picks the HTTP version on its own when it connects (for HTTP/2, during the TLS handshake), based on what the server and every proxy in between support. A user behind a corporate proxy may get HTTP/1.1 whatever the page wants, and there is no fetch option to insist on HTTP/2.
Gap 3: the page can't control frames or stream a request
Client and bidirectional streaming need the page to keep sending DATA frames on an open request while reading the response. fetch mostly sends a request body as one finished thing: a string, a Blob, FormData. Streaming a request body with a ReadableStream and duplex: "half" works only in Chromium-based browsers, only over HTTP/2 or HTTP/3, and, as the name says, it is half duplex: the response is not available until the request body is done. That rules out a true two-way stream.
Pages also get no say in HTTP/2 flow control, frame sizes, or stream priorities, which gRPC libraries tune on servers. Cancelling does work: AbortController stops a fetch, and the browser resets the stream.
| What native gRPC needs | What a web page gets |
|---|---|
Read the grpc-status trailer | No trailers API in fetch or XMLHttpRequest |
Send te: trailers | TE is a forbidden header; the browser drops it |
| HTTP/2 guaranteed | The browser and network pick 1.1, 2, or 3 |
| Stream request messages while reading responses | Request streaming only in Chromium, half duplex |
| Response streaming | Works: response.body is a ReadableStream in every modern browser |
The last row is the good news. Reading a response body piece by piece works everywhere. So one request in, many messages out, which is server streaming, is possible in a browser as long as the result does not depend on trailers. Both browser protocols are built on exactly that.
Check your understanding · Exercise 4
Which of these are real reasons a browser page can't call a native gRPC server directly? Select all that apply.
gRPC-Web and Connect: two ways into the browser
Both browser protocols solve the same gaps with the same trick: stop depending on trailers and HTTP/2, and put everything the client needs into normal headers and the body, which fetch can read. They differ in how far they move away from gRPC, and in what you must run on the server.
gRPC-Web: gRPC with the trailers moved into the body
gRPC-Web comes from the gRPC project itself and has been generally available since 2018. It keeps gRPC's framing, the 5-byte length prefix before each message, and changes three things.
- Content type
application/grpc-web+protofor binary, orapplication/grpc-web-textfor the same bytes in base64, which the originalgrpc-weblibrary needs for server streaming. - Trailers become the last frame of the body. That frame has its flag byte set to
0x80, and its payload is plain text lines:grpc-status:0andgrpc-message:. The client library reads the body to the end and parses the status itself. - Any HTTP version. The browser may use HTTP/1.1, 2, or 3; it no longer matters.
The price is a translator. Most gRPC servers don't speak gRPC-Web, so a proxy sits between the browser and the services and converts each call: gRPC-Web in from the browser, native gRPC out to the service, and back. The usual proxy is Envoy with its grpc_web filter. Some server frameworks speak gRPC-Web themselves, such as ASP.NET Core and Rust's tonic-web, which removes the proxy.
The other price is the limit on call kinds. gRPC-Web supports unary and server streaming only. Client streaming and bidirectional streaming are not part of it, for the reasons in the previous chapter. The original grpc-web JavaScript library adds one more catch: its binary mode supports only unary calls, and server streaming needs the base64 text mode, about 33% larger. Newer clients built on fetch streams, such as Connect-ES's gRPC-Web transport, stream binary fine.
Connect: an HTTP-first protocol from the same contracts
Connect is a protocol and a set of libraries from Buf, released in 2022 and developed at connectrpc.com. It keeps the .proto contract and the generated clients, but redesigns the wire format so that a unary call is just a normal HTTP request.
POST /orders.v1.OrderService/GetOrder HTTP/1.1
Host: api.example.com
Content-Type: application/json
Connect-Protocol-Version: 1
Connect-Timeout-Ms: 2000
{"id":"42"}
HTTP/1.1 404 Not Found
Content-Type: application/json
{"code":"not_found","message":"order 42 not found"}
- No envelope for unary calls. The body is just the message, as JSON (
application/json) or binary (application/proto). - Real HTTP status codes.
not_foundbecomes404,unauthenticatedbecomes401, so monitoring, CDNs, and DevTools understand the result. - Works over HTTP/1.1. Nothing depends on HTTP/2 or trailers.
GETfor read-only methods. Mark a methodoption idempotency_level = NO_SIDE_EFFECTS;and clients may send it as aGETwith the message in the query string, so browsers and CDNs can cache it. In Connect-ES the client opts in withuseHttpGet: true.- Streaming uses an envelope. Streams use
application/connect+jsonorapplication/connect+proto, the 5-byte prefix per message, and a final end-of-stream message that carries the status. Bidirectional streaming needs HTTP/2; the other kinds also work over HTTP/1.1. Browsers get unary and server streaming only, because client and bidirectional streaming need a streamed request body.
The key property: a Connect server, such as connect-go or connect-node, answers Connect, gRPC, and gRPC-Web on the same port. Backend services keep calling it with native gRPC, the browser calls it with Connect, and there is no proxy to run. If a company already runs Envoy with gRPC-Web, the Connect-ES client can switch to a gRPC-Web transport with one line, so the two approaches mix well.
See the difference on the wire
Pick a protocol and a call below to see roughly what DevTools would show, and whether a browser can make that call at all.
A browser can make this call. Works on any HTTP version with no proxy. Readable in DevTools, and errors use real HTTP status codes.
Request:
POST /orders.v1.OrderService/GetOrdercontent-type: application/jsonconnect-protocol-version: 1{"id":"42"}
Response:
200 OKcontent-type: application/json{"id":"42","status":"ORDER_STATUS_COOKING","totalCents":1637,"restaurantName":"Spice Route"}
| Property | Native gRPC | gRPC-Web | Connect |
|---|---|---|---|
| Callable from a browser | No | Yes | Yes |
| HTTP versions | HTTP/2 only | 1.1, 2, or 3 | 1.1, 2, or 3 |
| Needs a translating proxy | No | Usually (Envoy) | No |
| Unary and server streaming from a browser | Not applicable | Yes | Yes |
| Client and bidirectional streaming from a browser | Not applicable | No | No |
| JSON bodies readable in DevTools | No | No | Yes |
Cacheable GET for read-only calls | No | No | Yes |
| Where the status lives | Trailers | Last body frame | HTTP status, or the end-of-stream message |
Decision Your services speak gRPC. How should the web app reach them?
What we pick: For a new web app talking to protobuf services, Connect is the simplest path: no proxy, readable traffic, and the option of gRPC for everything else. Pick gRPC-Web when Envoy is already in place and you can't change the servers. Pick a BFF when screens need data from many services combined, or when the API is public.
Code generation: the contract becomes code
With REST you usually write the request code by hand, or generate it from an OpenAPI file if your team keeps one up to date. With gRPC, generation is not optional: nobody hand-writes varints. That turns out to be a feature. The .proto file is the only API description, and every client is produced from it.
buf breaking, instead of in four apps.The toolchain
The classic compiler is protoc, which reads .proto files and hands them to language plugins. Most teams now drive it through Buf's CLI, which adds linting, breaking-change checks, and a simple config file. For the web, the common plugin is protoc-gen-es from the Protobuf-ES project, which generates TypeScript message types and service descriptions.
# buf.gen.yaml
version: v2
plugins:
- local: protoc-gen-es
out: src/gen
opt: target=ts
# then, in the app or in CI:
$ buf lint
$ buf breaking --against ".git#branch=main"
$ buf generate
buf breaking is the safety net for the rules in chapter 2. It compares the new .proto files with the last released ones and fails the build if a field number was reused, a type changed, or a field was deleted. Its default rules are stricter than the wire rules: they also flag renames, because renames break generated code and JSON clients. Run it in the pull request of the team that owns the contract, and breaking changes stop at review instead of reaching users.
What the generated client looks like
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { OrderService, OrderStatus } from "./gen/orders/v1/orders_pb";
const transport = createConnectTransport({ baseUrl: "https://api.example.com" });
const orders = createClient(OrderService, transport);
// Unary: an async function with typed input and output.
const order = await orders.getOrder({ id: 42n });
if (order.status === OrderStatus.ON_THE_WAY) showMap(order);
// Server streaming: an async iterable you can cancel.
const controller = new AbortController();
for await (const update of orders.watchOrder({ id: 42n }, { signal: controller.signal })) {
renderStatus(update.status, update.etaMinutes);
}
Every method is an async function, every message is a plain typed object, and the stream is an async iterable that ends when the server finishes or the signal aborts. Your editor autocompletes OrderStatus.ON_THE_WAY, and if the order team removes eta_minutes, your build fails before the release instead of a user seeing "undefined min".
Details that bite
- 64-bit numbers. JavaScript numbers are exact only up to 2 to the power 53. Protobuf-ES maps
int64tobigint(hence42n), and the protobuf JSON mapping writesint64as a string. Code that doesorder.id + 1orJSON.stringify(order)needs care. - Enums are open. A newer server can send an enum value your old client has never heard of. The client keeps the number; your
switchneeds adefaultbranch that shows something sensible. - Generated code is code you ship. The runtime library and the generated descriptors add to the bundle. Older stacks (
google-protobufwithprotoc-gen-grpc-web) produced heavy class-based code with getters and setters; Protobuf-ES generates plain objects. Either way, measure it and load it only on the routes that need it. - Where the files live. Keep
.protofiles in one shared place, a monorepo folder, a separate repository, or the Buf Schema Registry, so every team generates from the same version.
Generated clients also plug into server-state libraries. Connect-Query, for example, turns each generated method into TanStack Query options, so caching, deduplication, and refetching work the same way they do for REST calls. The contract gives you the types; the server-state library still owns the cache.
Check your understanding · Exercise 5
Your generated Order type has id: bigint. Which of these will cause bugs? Select all that apply.
What breaks in production
Most gRPC problems a frontend team hits are not in the protocol. They are in the boxes between the browser and the service: proxies, load balancers, CDNs, and CORS rules, all of which were built with ordinary request and response traffic in mind.
Server streams that die or stall
A WatchOrder stream may stay open for 40 minutes while food cooks and travels. Many load balancers close a connection that has been quiet for 60 seconds, and the user's phone may switch from Wi-Fi to 4G. Unlike EventSource, which reconnects on its own, a gRPC-Web or Connect stream just ends with an error such as UNAVAILABLE, and nothing restarts it unless you do.
- Send heartbeats. Have the server send a small message, or a repeat of the current state, every 15 to 30 seconds, so idle timers never fire.
- Reconnect with backoff. On a retryable code, wait 1 s, then 2 s, then 4 s, with some randomness, and open the stream again.
- Resume, don't replay. Send the last version you saw (
since_version: 17) so the server sends only what changed, and the UI never jumps backwards.
The opposite failure is a stream that works but arrives in one burst at the end. Some proxies and CDNs buffer responses: they collect the whole body before passing it on. Locally every update shows up live; in production the user sees nothing for ten minutes and then every status at once. Turn off response buffering for streaming routes, and test streams through the real edge, not only on localhost.
"Missing status" errors and CORS
When the browser calls a different origin, CORS applies. gRPC-Web and Connect requests use a non-simple content type and custom headers, so the browser sends a preflight OPTIONS request first. The server must allow headers such as x-grpc-web, connect-protocol-version, and connect-timeout-ms. For gRPC-Web it must also expose grpc-status and grpc-message, because an error with no body puts them in the response headers.
If the expose rule is missing, the server answers correctly but the browser hides the status from JavaScript. The client then reports a confusing "missing trailer" or "unknown" error for every failure. When a gRPC-Web error looks impossible, check Access-Control-Expose-Headers first.
Other traps, quickly
| Symptom | Likely cause | Fix |
|---|---|---|
| Network tab shows unreadable bytes | Binary protobuf bodies | Use Connect with JSON in development, or a protobuf DevTools extension |
Large list call fails with RESOURCE_EXHAUSTED | Many gRPC libraries cap received messages at 4 MB by default | Paginate with page_token, or stream the list |
| New enum value shows as a raw number | Client generated from an older contract | Handle unknown values in a default branch; regenerate |
| "0 minutes" shown when there is no ETA | Plain proto3 field can't express "unset" | Make it optional and check for undefined |
| One backend server is overloaded, others idle | HTTP/2 keeps one long connection; a layer-4 load balancer pins it to one server | Balance per request at layer 7 (Envoy, a service mesh) or in the client |
| Errors lose their field details | Detailed errors travel in the grpc-status-details-bin header, which some proxies and tools drop | Use the error details API of your library and test that it survives the proxy |
Think first
Live order tracking works perfectly in staging. In production, users report the status jumps straight from "Placed" to "Delivered" with nothing in between, and only when the order is delivered. The stream uses Connect over a CDN. What is happening, and how do you confirm it?
Something between the browser and the server is buffering the response: most likely the CDN or a proxy in front of the service collects the whole streamed body and releases it when the stream ends. Staging probably skips the CDN, so it never showed up.
To confirm, open DevTools and watch the request's timing: the response body arrives in one piece at the end instead of in chunks. Calling the origin directly, bypassing the CDN, shows the updates live. The fix is to disable buffering (and caching) for the streaming path, or route streams around the CDN, and to add a heartbeat so idle timeouts don't cut it either.
When a frontend should use it, and when not
gRPC is not a replacement for REST or GraphQL in the browser. Each one answers a different question. REST asks "what resources exist?", GraphQL asks "what exactly does this screen need?", and gRPC asks "what functions can I call, with what exact types?". The REST API design and GraphQL lessons cover the first two in depth; here we only compare.
| Question | REST + JSON | GraphQL | gRPC via Connect or gRPC-Web |
|---|---|---|---|
| Contract | Optional (OpenAPI) | Required schema | Required .proto |
| Typed client | Generated if OpenAPI is kept current | Generated from schema and queries | Always generated |
| Shape of response | Server decides per endpoint | Client picks fields per query | Fixed per method |
| HTTP caching and CDNs | Excellent with GET | Lost with POST; persisted queries as GET bring it back | Only Connect GET for read-only methods |
| Server push to browser | Add SSE or WebSocket | Subscriptions, usually over WebSocket | Server streaming built in |
| Readable with curl and DevTools | Yes | Yes | Connect JSON yes; binary no |
| Public third-party API | The default | Common | Rare |
Good fits for gRPC in the browser
- Your backend already speaks protobuf. One contract, one generator, typed from the database service to the button. No hand-written API layer to drift out of date.
- Internal tools and dashboards where the same team owns both sides and nobody needs CDN caching.
- Typed server streams: log tailers, live order tracking, build progress, price tickers. Server streaming plus a generated type beats a hand-parsed SSE event.
- Many clients from one contract: web, iOS, and Android generate from the same files, so they can't disagree about a field.
Poor fits
- Public APIs that outside developers call with
curlor from any language without your tooling. - Pages that live on CDN caching, such as a product page served to millions, where a plain cacheable
GETURL is worth more than types. - Screens that combine many services. Fetching from six services in the browser means six round trips. Put a BFF or GraphQL layer in front, and let it make the gRPC calls inside the data center where they are fast.
- Anything needing two-way streams from the browser, such as chat or collaborative editing. Use WebSockets for that part.
In real products
Most of the gRPC traffic in the world never touches a browser. Google defines its Cloud APIs in .proto files, serves them over gRPC, and also offers REST and JSON versions generated from annotations in the same files (google.api.http), a pattern called transcoding. Kubernetes uses gRPC between the kubelet and container runtimes, etcd's v3 API is gRPC, and Envoy's own configuration APIs are gRPC streams. Netflix has written publicly about using gRPC and protobuf between its services.
Transcoding is worth knowing as a fourth browser option. Envoy's JSON transcoder or grpc-gateway can expose a REST-style JSON API, like GET /v1/orders/42, from the same .proto file, with no hand-written handlers. The browser gets ordinary cacheable REST; the services stay pure gRPC.
Check your understanding · Exercise 6
For which of these would calling gRPC services from the browser (through Connect or gRPC-Web) be a good choice? Select all that apply.
Using it in a system design interview
gRPC rarely is the main topic of a frontend interview. It shows up as a follow-up: "the backend team uses gRPC, does that change your design?", or "how would you get live updates from that service?". A short, precise answer earns more than a long one.
When to bring it up
- When the prompt says the backend is gRPC or protobuf, or the company is known for it.
- When you draw a BFF: say the BFF talks gRPC to the services inside the data center, and REST or GraphQL to the browser.
- When you need a typed server stream and the services already use protobuf.
- Not as the default browser API for a public, cache-heavy product. Saying so, with the reason, is itself a good signal.
What to draw and say
Draw the browser, an edge box, and the services. Label the browser-to-edge arrow with the protocol ("Connect, JSON, HTTP/1.1 or 2" or "gRPC-Web") and the edge-to-services arrows with "gRPC, HTTP/2". If the edge is Envoy, say it translates gRPC-Web to gRPC. Then name the call kinds you need: unary for loads and mutations, server streaming for live status, and a WebSocket for anything two-way.
| Level | What a strong answer covers |
|---|---|
| Junior | gRPC is typed RPC with protobuf over HTTP/2; browsers can't call it directly; there is a browser variant that needs a proxy. |
| Mid | Why: no trailers in fetch, no HTTP/2 control. gRPC-Web supports unary and server streaming only, through Envoy. Generated TypeScript clients and safe contract changes. |
| Senior | Connect versus gRPC-Web versus a BFF, with the trade-offs: caching, DevTools, CORS, proxy buffering, stream reconnects with resume, deadlines, int64 as bigint, and buf breaking in CI. |
Check your understanding · Exercise 7
An interviewer asks about calling a gRPC chat service from the browser. Which statements would be correct to say? Select all that apply.
Summary
- gRPC bundles three ideas: a
.protocontract, compact protobuf messages, and a transport over HTTP/2 with four call kinds (unary, server, client, and bidirectional streaming). - Binary protobuf sends field numbers, not names. Renaming is free in binary (not in JSON); reusing or renumbering a shipped field breaks old clients. Reserve retired numbers and run
buf breakingin CI. - Protobuf is several times smaller than raw JSON, but after gzip the gap is small. Its real wins are CPU, small stream messages, and the typed contract.
- Browsers can't speak native gRPC:
fetchexposes no trailers (wheregrpc-statuslives), forbids theTEheader, can't force HTTP/2, and can't do full-duplex request streams. - gRPC-Web moves trailers into the body and needs a translating proxy such as Envoy, unless the server speaks it natively. It supports unary and server streaming only.
- Connect makes unary calls plain HTTP: JSON or binary, real status codes, HTTP/1.1, optional cacheable
GET. Connect servers also speak gRPC and gRPC-Web, with no proxy. - Most frontends meet gRPC behind a BFF. Call it from the browser when the backend already speaks protobuf and you want one typed contract and server streams; avoid it for public, cache-heavy, or two-way features.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
Why can't a browser call a gRPC server directly, and what do gRPC-Web and Connect change?
Native gRPC puts the result of every call, grpc-status, in HTTP/2 trailers, and requires HTTP/2 and the te: trailers header. Browsers expose HTTP only through fetch and XMLHttpRequest: no trailers API, TE is a forbidden header, the page can't choose the HTTP version, and request streaming is limited to Chromium in half-duplex mode.
gRPC-Web moves the trailers into a final frame of the response body, so any HTTP version works, and a proxy like Envoy translates to native gRPC. Connect goes further: unary calls are plain POSTs (or GETs) with JSON or binary bodies and real HTTP status codes, and a Connect server answers Connect, gRPC-Web, and gRPC directly, with no proxy.
Question 2
You need live delivery status in the web app. The courier service is gRPC. Design the path.
Add a server-streaming method, WatchOrder(GetOrderRequest) returns (stream Order), and call it from the browser through Connect (or gRPC-Web via Envoy if that is already deployed). Server streaming is supported by both browser protocols.
Then handle the real-world parts: a heartbeat every 15 to 30 seconds so idle timeouts don't close the stream, reconnect with exponential backoff on UNAVAILABLE, resume with the last seen version, disable response buffering at the CDN or proxy, and abort the stream with an AbortController when the user leaves the page. If the user also needs to send things, use unary calls for those, or a WebSocket if it must be a true two-way stream.
Question 3
Is protobuf really smaller than JSON for a web app?
Uncompressed, usually several times smaller, because field names never travel and integers are varints. In an illustrative 100-order list, JSON was about 12.8 KB and protobuf about 3.3 KB.
But browsers already gzip or Brotli JSON, and compression removes most repeated names: the same data was about 1.6 KB as gzipped JSON and 1.4 KB as gzipped protobuf. So for the browser the byte win is modest. The stronger arguments are CPU cost on busy servers, small uncompressed stream messages, and above all the shared typed contract.
Question 4
How do you change a protobuf contract without breaking clients that are already deployed?
Follow the numbering rules: add fields with new numbers, never reuse or renumber a shipped field, reserve the numbers and names of removed fields, and don't change a field's wire type. Renames are safe in the binary format but not for JSON clients. Use optional where "unset" and "zero" must differ, and handle unknown enum values with a default branch.
Enforce it with buf breaking against the last released version in CI, and remember that old browser tabs, mobile apps, and new servers coexist for days or weeks, so changes must work in both directions.
Question 5
When would you not call gRPC services from the browser?
For public APIs that outside developers use, for cache-heavy public pages that benefit from CDN-cached GET URLs, and for screens that need data from many services, where a BFF or GraphQL layer can do the fan-out inside the data center in one browser round trip.
Also for two-way real-time features like chat or collaborative editing, because neither gRPC-Web nor Connect supports client or bidirectional streaming from browsers; a WebSocket fits better there.
References
- gRPC: Introduction and core concepts Services, call kinds, deadlines, and cancellation from the official docs.
- gRPC over HTTP/2 (protocol spec) Headers, length-prefixed messages, and trailers on the wire.
- gRPC-Web protocol spec How gRPC-Web differs from gRPC, including trailers in the body.
- Protocol Buffers: Encoding Tags, wire types, varints, and zigzag encoding.
- Protocol Buffers: proto3 language guide Field numbers, reserved fields, optional, and safe updates.
- Connect protocol reference Unary and streaming wire formats, GET requests, and error codes.
- MDN: Forbidden request header Why a page can't send
TEand other headers.
Read next
- REST for frontendsA way to shape an HTTP API around resources and a few standard verbs, so the frontend can fetch, cache, retry, and handle errors in the same predictable way everywhere.
- GraphQLA typed query language for APIs: the client asks one endpoint for exactly the fields a screen needs, across many related objects, in one round trip, and pays for it with harder caching and more work on the server.
- Backend for frontend (BFF)A small server, usually owned by the frontend team, that gathers data from many backend services and returns exactly what one screen of one kind of client needs.
- API contracts, versioning, and errorsThe written agreement between the frontend and the API: a typed schema both sides are checked against, rules for changing it without breaking old clients, and one error shape the UI can always understand, including for partial failures and safe retries.
- HTTP/1.1, HTTP/2, and HTTP/3The same requests carried three ways (one at a time per TCP connection, many at once on one TCP connection, or many at once on QUIC over UDP), which decides bundling, sprites, sharding, and how a lossy network feels.
- Server-sent eventsOne HTTP response that never finishes: the server keeps writing small text events to it, and the browser's
EventSourceparses them, reconnects on its own, and resumes where it left off. The simplest push channel when data flows from server to client.