gRPC-Web Failed the Web
gRPC-Web is a strange protocol. It only exists because browsers can’t speak gRPC. The reason is that HTTP trailers are used for the final status of gRPC calls, and browsers don’t expose them to JavaScript.
Teams still wanted gRPC’s schema-first, type-safe model in the browser, so gRPC-Web moved the trailers into the response body and kept the rest of gRPC’s framing and semantics. That got gRPC into browsers, at the price of a protocol the rest of the web doesn’t understand.
Connect is what gRPC-Web should have been: a protocol that keeps the Protobuf contract and generated clients while using standards that the web has developed for three decades.
I’ll come back to Connect. But first, I need to be precise about what I mean by gRPC-Web.
What’s going on with gRPC-Web?
grpc/grpc-web is Google’s JavaScript client for calling a gRPC service from a browser. Traditionally, to make this work, you need to put a translating proxy in between, and the README points at Envoy for that.
The protocol it speaks is loosely specified in the gRPC repository, and it differs from gRPC in a few places. Because browser APIs don’t expose trailers to JavaScript, trailers were moved into the response body as a final frame for each streaming and unary call. In addition, it made a few other practical changes. The content type is application/grpc-web instead of application/grpc (in practice with a suffix naming the encoding, like the application/grpc-web+proto you’ll see below), so a server can tell which protocol it is being asked to speak. And with a new application/grpc-web-text content type, the stream of data is base64-encoded to work around XHR, which can only read a response incrementally as text. Browsers haven’t needed that since fetch() learned to stream binary response bodies, but the official gRPC-Web client is built on XHR and still requires the text mode for server streaming. Besides those things, it stays recognizably gRPC: the five-byte message prefix, the grpc-status code “trailers”, and POST-only requests. That was the design goal: keep gRPC’s semantics and framing so that servers, proxies, and client libraries could be adapted instead of rewritten.
It worked. Protobuf contracts are now reaching the browser with gRPC-Web, and the same schema that can generate your Go server stubs can also generate a usable, typed client for your frontend. I still have problems with this spec, but the current state of the grpc/grpc-web project comes first.
The project is effectively in maintenance mode. Its roadmap says “we do not plan to be adding new features going forward,” citing the archival of Google Closure and the minimal maintenance of Protobuf JavaScript. Instead of pointing at one of the other clients that implement gRPC-Web, it recommends gRPC-Gateway, which does not implement gRPC-Web at all; it transcodes between a JSON REST API and gRPC. I consider this a strange choice.
The protocol now has a life outside of the original reference client. Envoy still speaks gRPC-Web as a proxy in front of an ordinary gRPC server, and several server and client implementations speak it directly. The protocol is still widely supported, and that is where my complaints lie.
Hidden failures
HTTP statuses exist for a reason. 200 for success, 404 for missing, 500 for a server failure. This is so ingrained in us that most people know what a 404 is.
gRPC completely tossed this convention away, despite being built on top of HTTP. The HTTP status on a gRPC response is 200 OK whenever the transport worked, and you have to dig the outcome of the RPC itself out of a grpc-status trailer at the end. There’s a defensible reason for the trailer: a streaming RPC can fail after the response has already started, long after the status line went out, so the status has to come last. gRPC just applies the same rule to unary calls too.
gRPC-Web moves the status somewhere even harder to see. The trailers migrate into the response body, so a load balancer or web application firewall that could at least have parsed HTTP trailers now has to understand gRPC-Web’s framing to notice errors at all.
Let’s see an example. The database is down, so the RPC fails with internal. Follow it out through the layers between the server and the browser:
Every generic HTTP layer above the client reads this interaction as a success. The failure does exist, but only the server and client know anything about it. So good luck implementing robust monitoring, load shedding mitigations, or reusing HTTP alerting rules. All of the existing infrastructure needs to understand gRPC-Web deeply to get this kind of observability, and most of it just doesn’t.
Put those responses on a dashboard, and it will show a 100% success rate while the database is down. That should be alarming to you.
Opaque payloads
Streaming requires extra framing because several messages are sent inside of a single HTTP body, so something has to mark where the message ends. gRPC puts a five-byte prefix in front of every message, a compression flag followed by a four-byte length. gRPC parsers use that four-byte length to delimit messages in the stream.
Even though the framing exists only to facilitate streaming, unary calls inherit the same prefix anyway. HTTP already has the machinery required to handle a single request with a single response: Content-Length says how big the request or response is, Content-Type tells you what shape the payload is in, and Content-Encoding/Accept-Encoding handle compression.
Here is what the extra frame means in practice, against the live Eliza demo. With a generic tool such as curl, you have to construct the gRPC-Web frame yourself:
printf '\x00\x00\x00\x00\x0f\x0a\x0dI feel happy.' | curl -sS --data-binary @- \
-H 'Content-Type: application/grpc-web+proto' \
https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say | xxdThose first five bytes are the prefix: a zero flag, then 0f for the fifteen bytes that follow. The response is framed the same way:
00000000: 0000 0000 2a0a 2847 6f6f 642c 2074 656c ....*.(Good, tel
00000010: 6c20 6d65 206d 6f72 6520 6162 6f75 7420 l me more about
00000020: 7468 6573 6520 6665 656c 696e 6773 2e80 these feelings..
00000030: 0000 0020 6772 7063 2d6d 6573 7361 6765 ... grpc-message
00000040: 3a20 0d0a 6772 7063 2d73 7461 7475 733a : ..grpc-status:
00000050: 2030 0d0a 0..Eliza’s reply is in there, and so is grpc-status: 0, sitting in the body rather than in a trailer. Eliza varies its replies, so your bytes will differ, but the shape of the data stays the same.
As you can see, you can send gRPC-Web requests using standard HTTP tooling, but you have to build the framing yourself on the way out and pick it apart on the way back.
Where the contract ends
I mentioned earlier that the gRPC-Web roadmap recommends switching to gRPC-Gateway instead. Let’s unpack that. It can absolutely expose a web-friendly API from a gRPC service, but it also throws out what is so useful about having a contract-driven API and forces you to make a separate contract for web clients or to write web clients by hand.
With gRPC-Web, one schema generates the server and browser clients, with a proxy translating the protocol in between:
gRPC-Gateway generates a reverse proxy from that schema. The browser no longer talks to the Protobuf-defined RPC API; it talks to a JSON/HTTP API in front of it. If you want generated frontend clients too, the conventional route is to generate OpenAPI from Protobuf and then generate the client from OpenAPI:
That gets you back to something you already had: a schema-generated, type-safe client. The difference is that the Protobuf contract now stops at the gateway, and changes have another generated representation to pass through before they reach the frontend.
The gateway itself is generated from the schema. protoc-gen-grpc-gateway emits handlers and translation code for each HTTP binding, so adding an RPC or changing its HTTP mapping means regenerating that code. If the gateway runs as a separate proxy, that also means another generated artifact that has to be deployed alongside changes to the API.
If you want a separate public JSON/HTTP API, that boundary can make perfect sense. If the goal is to bring the Protobuf contract into the browser, generating OpenAPI just to regenerate a typed client is needless indirection, with another place for the two schema languages to disagree.
Nearly eight years after GA, gRPC-Web’s own recommended alternative for browser clients is a translation layer that makes the browser stop speaking gRPC-Web. We should be able to keep the Protobuf-defined contract without adding another translation layer, while still working with standard web tooling.
What Connect does instead
We built Connect at Buf. It keeps the Protobuf service contract and the generated clients, and gives up the parts of gRPC that only ever made sense as a backend transport.
Failures are HTTP failures where possible. Connect uses standard HTTP status codes for the coarse outcome and the body for the detail. Here is the same database outage, reported to the same five layers:
Nothing in that path had to learn a custom protocol to get the answer right. Your CDN sees a 500 and logs a 500, your error dashboard counts it, and your client still gets internal plus whatever typed details the server attached. On the wire, a Connect error is that status code and a JSON body: here, a request the server couldn’t unmarshal:
HTTP/2 400
content-type: application/json
{"code":"invalid_argument","message":"unmarshal message: invalid value for string type"}Streaming is the exception, for the reason described earlier: once the response has started, the status line has already been sent. Similar to gRPC and gRPC-Web, Connect also returns 200 for streaming calls and reports failures (or success) in an EndStreamResponse. Connect only deviates from ordinary HTTP semantics where the shape of the call forces it.
A unary body is just the message. Content-Type is application/json or application/proto, and the body is the serialized message with nothing wrapped around it. With JSON, curl and the Network panel show the payload directly. Protobuf is still binary and still needs the schema to decode, but there is no length prefix to strip and no trailer frame to find. Here is the same Eliza call that we made earlier but with Connect:
curl -sS -H 'Content-Type: application/json' \
-d '{"sentence":"I feel happy."}' \
https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say{ "sentence": "When do you usually feel happy?" }This is an ordinary HTTP request, and the response comes back as you’d expect from any “normal” API.
It is the same server and the same path the gRPC-Web call hit earlier. Only the content type and the body changed. The demo is a ConnectRPC server, which speaks Connect, gRPC-Web, and gRPC on the same port. The Content-Type header is what selects the protocol. This setup makes it trivial to integrate with existing gRPC/gRPC-Web systems.
Remember how gRPC-Web always uses POST, which foregoes automatic browser caching? With Connect, you can opt in to using GET for methods marked as side-effect-free. With this setup, CDNs and browser caches work the way they do for any other web API once you set the Cache-Control headers you would set anyway.
The contract reaches the browser. A ConnectRPC server speaks Connect over ordinary HTTP, so the browser talks to it directly with a client generated from the same schema. No translating proxy in the request path, no second schema generated from another schema, and no second generated artifact to redeploy every time the first one changes:
Protobuf-ES generates the messages and service descriptors that Connect-ES turns into typed clients, and they speak either Connect or gRPC-Web, so you can switch the browser over before touching the server. Doing that also gets you off the maintenance-mode grpc/grpc-web client and onto a maintained Protobuf runtime, whichever protocol you end up on. ConnectRPC joined the CNCF Sandbox in April 2024.
Less to run. A ConnectRPC server is an ordinary HTTP server, so the translating proxy leaves the deployment entirely: no Envoy between the browser and the service, no gRPC-Web filter to configure, no extra hop to monitor or to reproduce on a laptop. If you already run a proxy for TLS termination or routing, it goes back to doing that instead of rewriting frames.
gRPC in the browser
gRPC-Web chose compatibility with gRPC, and that choice kept implementations simple and put Protobuf contracts in the browser nearly eight years ago. I think it was a great step in the right direction. But it changed only what the browser forced it to change, and for unary calls it left HTTP’s existing machinery on the table: a status code that says what happened, a body that doesn’t have framing bits around it, support for GET requests that a CDN can cache.
Connect uses those semantics without giving up the Protobuf contract or the generated clients. That makes the browser one more client in the modern Protobuf workflow rather than the one that requires extra translation proxies.
You don’t have to switch all at once. ConnectRPC serves gRPC, gRPC-Web, and Connect from the same server on the same port, so you can move one client at a time and leave the rest alone.
Try ConnectRPC for yourself at connectrpc.com.