cf.completefrontend
My progress
FUNDAMENTALS · MODULE 3

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, so 1734567890 is 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 driverId to courierId. Nothing stops the deploy. The order service reads undefined, 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.

Two side-by-side panels of the same backend. In both, an order service calls a courier service and a pricing service. Before, with JSON over HTTP: field names travel in every message, each team writes its own client, and renamed fields break in production. After, with gRPC: messages are binary with numbered fields, clients are generated from the contract, and a breaking rename fails the build.Before: JSON over HTTPOrder servicecalls with JSONCourierPricingnames in every messagehand-written clientsrenames break in prodAfter: gRPCOrder servicecalls with gRPCCourierPricingbinary, numbered fieldsgenerated clientsrenames fail the build
The calls are the same; what changes is the format, who writes the client, and when a mismatch is caught.

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.

  1. A contract language. You describe services, methods, and messages in a .proto file. That file is the single source of truth for both sides.
  2. A binary format. Messages are encoded with Protocol Buffers (protobuf), a compact binary format that is smaller and faster to read than JSON.
  3. 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.

text
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);
}
The number after each field is not a value. It is the field's permanent ID on the wire.

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_name to store_name does 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 reserved so nobody reuses them by mistake.
  • Don't change a field's type in a way that changes its wire encoding, such as string to int32. Some changes are compatible on the wire (int32 to int64), 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.

  • 08 is field 1, wire type 0: (1 << 3) | 0 = 8.
  • 12 is field 2, wire type 2: (2 << 3) | 2 = 18, which is 0x12.
  • 18 is field 3, wire type 0: (3 << 3) | 0 = 24, which is 0x18.

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.

The protobuf encoding of a user with id 1234, name Asha, and online true, as 11 bytes in hexadecimal: 08 d2 09 12 04 41 73 68 61 18 01. Bytes 08, 12, and 18 are tags for fields 1, 2, and 3. d2 09 is the varint 1234. 04 is the length of the string, followed by the four letters of Asha. 01 is true. Below, two bars to scale: JSON takes 39 bytes, protobuf takes 11.User { id: 1234, name: "Asha", online: true }08d2091204417368611801tag1234taglenA s h atagtruefield 1: idfield 2: namefield 3: onlineJSON: 39 Bprotobuf: 11 B
Each field is a tag, then a value. The tag 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.

Try:

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.

Illustrative sizes for one made-up data set, not a benchmark.
EncodingOne order100 orders100 orders, gzipped
JSON (protobuf's JSON mapping, camelCase names)127 bytes12.8 KB1.6 KB
Protobuf binary32 bytes3.3 KB1.4 KB
An illustrative bar chart, to scale, of one made-up list of 100 orders. As JSON it is 12.8 kilobytes; as protobuf, 3.3 kilobytes. Gzipped, the JSON is 1.6 kilobytes and the protobuf 1.4 kilobytes.JSON12.8 KBprotobuf3.3 KBJSON, gzipped1.6 KBprotobuf, gzipped1.4 KB100 orders: id, status, total, restaurant, ETA, timestamp
Raw protobuf is about 4 times smaller, but compression removes most of JSON's repeated names. Illustrative numbers for one data set, not a benchmark.

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.

  1. The client opens a stream and sends a HEADERS frame: :method POST, :path /orders.v1.OrderService/GetOrder, content-type: application/grpc, te: trailers, and often grpc-timeout: 2S for a 2-second deadline.
  2. It sends a DATA frame 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.
  3. The server replies with HEADERS: :status 200 and content-type: application/grpc.
  4. It sends the response message in DATA, with the same 5-byte prefix.
  5. It ends with trailers: grpc-status: 0 and, on errors, grpc-message with a description. Only now does the client know whether the call succeeded.
A sequence diagram of one unary gRPC call between a client and a server over one HTTP/2 stream. The client sends a HEADERS frame with POST to the GetOrder path, then a DATA frame with a 5-byte prefix and the request. The server replies with a HEADERS frame with status 200, a DATA frame with a 5-byte prefix and the Order, and finally a trailers HEADERS frame with grpc-status 0, which is where the result of the call lives.ClientgRPC serverHEADERS: POST .../GetOrderDATA: 5-byte prefix + requestHEADERS: :status 200DATA: 5-byte prefix + Ordertrailers: grpc-status 0
The HTTP status is 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.

http
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
A success, and a "trailers-only" error, where the status rides in the only HEADERS frame.

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.

CodeNameRough HTTP twinWhat the UI does
0OK200Show the data
3INVALID_ARGUMENT400Show field errors; don't retry
4DEADLINE_EXCEEDED504Retry if the call is safe to repeat
5NOT_FOUND404Show an empty or missing state
7PERMISSION_DENIED403Explain the user can't do this
14UNAVAILABLE503Retry with backoff; the server is briefly down
16UNAUTHENTICATED401Refresh 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.

KindIn the .protoClient sendsServer sendsExample
Unaryrpc GetOrder(Req) returns (Order)One messageOne messageLoad an order
Server streamingreturns (stream Order)One messageMany, over timeLive order status until delivery
Client streamingrpc Upload(stream Chunk)Many, over timeOne at the endUpload GPS points, get a summary
Bidirectional(stream Msg) returns (stream Msg)ManyMany, at the same timeChat, or a courier app syncing both ways
A timeline of the four gRPC call kinds, with time running left to right. Client messages sit above each axis and server messages below. Unary: one client message, then one server message. Server streaming: one client message, then five server messages over time. Client streaming: four client messages, then one server message at the end. Bidirectional: client and server messages interleave freely.C: client sends, S: server sendsUnaryCSServer streamCSSSSSClient streamCCCCSBidirectionalCCCSSSStime
Every kind is one HTTP/2 stream. Only the number and timing of messages in each direction differ.

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?

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.

A layered stack. At the top, the web page, where JavaScript sees only fetch: a status, headers, and a body stream. Below it, the browser's network stack, hidden from the page: it picks HTTP/1.1, 2, or 3 on its own, and handles HTTP/2 frames, trailers, and flow control. At the bottom, the gRPC server. Notes on the right: the page gets status, headers, and body; trailers, frames, and priorities are hidden; the page can't send the TE header or pick HTTP/2.Web page: what JavaScript seesfetch()status, headers, body streamBrowser network stack: hiddenHTTP version choice1.1, 2, or 3, picked by the browserHTTP/2 framesHEADERS, DATA, trailersgRPC servergets: status,headers, bodyhidden: trailers,frames, prioritiescan't send TEor pick HTTP/2
Everything gRPC depends on, trailers, 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 needsWhat a web page gets
Read the grpc-status trailerNo trailers API in fetch or XMLHttpRequest
Send te: trailersTE is a forbidden header; the browser drops it
HTTP/2 guaranteedThe browser and network pick 1.1, 2, or 3
Stream request messages while reading responsesRequest streaming only in Chromium, half duplex
Response streamingWorks: 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+proto for binary, or application/grpc-web-text for the same bytes in base64, which the original grpc-web library 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:0 and grpc-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.

Two architectures. Top, gRPC-Web: the browser sends gRPC-Web over any HTTP version to an Envoy proxy with the grpc_web filter, which forwards native gRPC over HTTP/2 to the order service. Bottom, Connect: the browser sends Connect with JSON straight to a Connect server, which also accepts native gRPC from other backend services. No proxy is needed.gRPC-WebBrowsergRPC-WebEnvoygrpc_web filterOrder servicenative gRPCany HTTP versionHTTP/2ConnectBrowserConnect, JSONConnect serverConnect, gRPC-Web, gRPCOther servicenative gRPCno proxy
gRPC-Web adds a translator in front of unchanged services. A Connect server speaks the browser's protocol and gRPC on the same port.

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.

http
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"}
A Connect unary call: plain JSON, a real HTTP status, readable in DevTools and with curl.
  • No envelope for unary calls. The body is just the message, as JSON (application/json) or binary (application/proto).
  • Real HTTP status codes. not_found becomes 404, unauthenticated becomes 401, so monitoring, CDNs, and DevTools understand the result.
  • Works over HTTP/1.1. Nothing depends on HTTP/2 or trailers.
  • GET for read-only methods. Mark a method option idempotency_level = NO_SIDE_EFFECTS; and clients may send it as a GET with the message in the query string, so browsers and CDNs can cache it. In Connect-ES the client opts in with useHttpGet: true.
  • Streaming uses an envelope. Streams use application/connect+json or application/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/GetOrder
  • content-type: application/json
  • connect-protocol-version: 1
  • {"id":"42"}

Response:

  • 200 OK
  • content-type: application/json
  • {"id":"42","status":"ORDER_STATUS_COOKING","totalCents":1637,"restaurantName":"Spice Route"}
PropertyNative gRPCgRPC-WebConnect
Callable from a browserNoYesYes
HTTP versionsHTTP/2 only1.1, 2, or 31.1, 2, or 3
Needs a translating proxyNoUsually (Envoy)No
Unary and server streaming from a browserNot applicableYesYes
Client and bidirectional streaming from a browserNot applicableNoNo
JSON bodies readable in DevToolsNoNoYes
Cacheable GET for read-only callsNoNoYes
Where the status livesTrailersLast body frameHTTP status, or the end-of-stream message

Decision Your services speak gRPC. How should the web app reach them?

gRPC-Web through Envoy

Run Envoy with the grpc_web filter in front of the services

Generate a TypeScript client; use a gRPC-Web transport

Pros

  • Services stay unchanged
  • Envoy may already be your edge proxy
  • Typed end to end

Cons

  • One more component to run, configure, and debug
  • Binary bodies are opaque in DevTools
  • Every call is a POST, so no HTTP caching

Connect

Serve the API with a Connect library (Go, Node, and others)

Use the Connect transport in the browser

Pros

  • No proxy
  • JSON in DevTools, works with curl
  • Real HTTP status codes; GET caching for read-only calls
  • Same server still serves gRPC to other services

Cons

  • Server must use a Connect library or a translating proxy
  • Smaller ecosystem than classic gRPC

Backend for frontend in REST or GraphQL

A BFF calls the gRPC services

The browser calls the BFF with plain HTTP

Pros

  • Browser code needs nothing special
  • The BFF can shape and combine data per screen
  • Easy CDN caching

Cons

  • A hand-written API layer to maintain
  • Types must be bridged, for example by generating them from the BFF's schema

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.

A tree. At the root, one .proto contract kept in a shared repository. It feeds buf, which lints it, checks it for breaking changes, and generates code. From buf, four branches: TypeScript for the web app, Swift for the iOS app, Kotlin for the Android app, and Go for the services..protoone contract, shared repobuflint, breaking, generateTypeScriptweb appSwiftiOS appKotlinAndroid appGoservices
One contract, many generated clients. A breaking change is caught once, by 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.

text
# 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
Lint the contract, fail on breaking changes against main, then generate TypeScript.

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

ts
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);
}
Connect-ES v2. Swap in createGrpcWebTransport to speak gRPC-Web to an Envoy proxy instead.

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 int64 to bigint (hence 42n), and the protobuf JSON mapping writes int64 as a string. Code that does order.id + 1 or JSON.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 switch needs a default branch that shows something sensible.
  • Generated code is code you ship. The runtime library and the generated descriptors add to the bundle. Older stacks (google-protobuf with protoc-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 .proto files 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.
A state machine for a server stream in the browser. Connecting moves to Streaming when the stream opens. Streaming stays alive with a heartbeat every 20 seconds. When the server ends the stream, it moves to Done. On UNAVAILABLE or an idle timeout, it moves to Reconnecting, which waits 1, 2, then 4 seconds and goes back to Connecting, sending the last version it saw. After 5 failed tries it moves to Gave up, which shows a retry button.Connectingopen streamStreamingheartbeat 20 sDoneorder deliveredReconnectingsend last versionGave upshow retry buttonopenedserver endsUNAVAILABLE or timeoutafter 1 s, 2 s, 4 s5 tries failed
Nothing in gRPC-Web or Connect reconnects for you. The Reconnecting state, with backoff and a resume version, is code you write.

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

SymptomLikely causeFix
Network tab shows unreadable bytesBinary protobuf bodiesUse Connect with JSON in development, or a protobuf DevTools extension
Large list call fails with RESOURCE_EXHAUSTEDMany gRPC libraries cap received messages at 4 MB by defaultPaginate with page_token, or stream the list
New enum value shows as a raw numberClient generated from an older contractHandle unknown values in a default branch; regenerate
"0 minutes" shown when there is no ETAPlain proto3 field can't express "unset"Make it optional and check for undefined
One backend server is overloaded, others idleHTTP/2 keeps one long connection; a layer-4 load balancer pins it to one serverBalance per request at layer 7 (Envoy, a service mesh) or in the client
Errors lose their field detailsDetailed errors travel in the grpc-status-details-bin header, which some proxies and tools dropUse 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?

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.

QuestionREST + JSONGraphQLgRPC via Connect or gRPC-Web
ContractOptional (OpenAPI)Required schemaRequired .proto
Typed clientGenerated if OpenAPI is kept currentGenerated from schema and queriesAlways generated
Shape of responseServer decides per endpointClient picks fields per queryFixed per method
HTTP caching and CDNsExcellent with GETLost with POST; persisted queries as GET bring it backOnly Connect GET for read-only methods
Server push to browserAdd SSE or WebSocketSubscriptions, usually over WebSocketServer streaming built in
Readable with curl and DevToolsYesYesConnect JSON yes; binary no
Public third-party APIThe defaultCommonRare

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 curl or from any language without your tooling.
  • Pages that live on CDN caching, such as a product page served to millions, where a plain cacheable GET URL 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.
A decision flowchart. First: is it a public API or a CDN-cached page? If yes, use REST with JSON. If no: does the backend speak protobuf? If no, use REST or GraphQL. If yes: does the screen join many services? If yes, use a backend for frontend that calls gRPC. If no, call the services from the browser with Connect, and use a WebSocket for any two-way parts.Public API or CDN page?yesREST + JSONnoBackend speaks protobuf?noREST or GraphQLyesScreen joins many services?yesBFF that calls gRPCnoConnect from browserWebSocket for two-way parts
Calling gRPC from the browser is the last branch, not the first: it fits when the backend already speaks protobuf and each screen talks to one or two services.

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.

LevelWhat a strong answer covers
JuniorgRPC is typed RPC with protobuf over HTTP/2; browsers can't call it directly; there is a browser variant that needs a proxy.
MidWhy: 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.
SeniorConnect 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 .proto contract, 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 breaking in 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: fetch exposes no trailers (where grpc-status lives), forbids the TE header, 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?

Question 2

You need live delivery status in the web app. The courier service is gRPC. Design the path.

Question 3

Is protobuf really smaller than JSON for a web app?

Question 4

How do you change a protobuf contract without breaking clients that are already deployed?

Question 5

When would you not call gRPC services from the browser?

References

Read next