gRPC-Web exists because browsers can’t speak gRPC. The biggest problem is HTTP trailers: gRPC puts the final status of a call there, and browsers don’t expose them to JavaScript.

Teams wanted gRPC’s schema-first, type-safe model in the browser anyway, so gRPC-Web was created to move the trailers into the response body while keeping most of gRPC’s framing and semantics. That made gRPC work in browsers, and left it speaking 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, the trailers move into the response body as a final frame for each streaming or unary call. The content type becomes application/grpc-web instead of application/grpc, so a server can tell which protocol it is being asked to speak. And application/grpc-web-text base64-encodes the stream in chunks for clients that can’t read a binary response. Underneath, it stays recognizably gRPC: the five-byte message prefix, the grpc-status codes, POST-only requests, metadata as headers. 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 reached the browser, and the same schema that generated your Go server stubs generated a typed client for your frontend. I still have problems with it, 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 outgrew the 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 largely replaces that model with its own. When the gRPC transport completes normally, the HTTP status is 200 OK even if the RPC itself failed. The RPC outcome travels in grpc-status, sent in HTTP trailers after the body.

Streams need a late status. A streaming RPC can fail after the response has already started, long after the status line went out, so the outcome has to arrive at the end. gRPC applies that to every call, streaming or not.

gRPC-Web buries it deeper. Because browser APIs don’t expose trailers to JavaScript, the grpc-status moves to the body. An error raised before any data is written can still come back as a trailers-only response, with the status in the response headers. Once the response has started, an intermediary has to understand gRPC-Web framing, locate the trailer frame, decode it, and extract grpc-status, with the base64 layer on top in grpcwebtext mode. Both paths need something that speaks gRPC-Web to recover what HTTP status codes normally make explicit.

Take a server-side failure. The database is down, so the RPC fails with internal. Follow it out through the layers between the server and the browser:

GRPC / GRPC-WEB THE DATABASE IS DOWN STATUS ERROR HTTP/2 200 trailer: grpc-status: 13 API_GATEWAY OK LOAD_BALANCER OK ERROR_DASHBOARD OK DEV_TOOLS OK GENERATED_CLIENT error
GRPC / GRPC-WEB HTTP/2 200 trailer: grpc-status: 13 API_GATEWAY OK LOAD_BALANCER OK ERROR_DASHBOARD OK DEV_TOOLS OK GENERATED_CLIENT error

Every generic HTTP layer above the client reads it as a success. The failure does exist, but in exactly one place, at the far edge, in the one participant that cannot retry through a healthier backend, shed load, or page anybody. By the time the generated client extracts grpc-status from the body, the response has already passed through infrastructure that could have counted it or routed around it using ordinary HTTP semantics.

Standard HTTP monitoring can report 100% successful responses while every RPC is failing. Unless your infrastructure understands gRPC, 200 OK is the only signal it sees.

Opaque payloads

Streaming requires extra framing because several messages share one HTTP body, so something has to mark where each one ends. gRPC puts a five-byte prefix in front of every message, a compression flag followed by a four-byte length. The length separates one message from the next. gRPC-Web keeps that framing and adds its own trailer frame to carry the HTTP trailers that browsers cannot read.

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 | xxd

Those 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 here, with great effort you can send gRPC-Web requests using standard HTTP tooling, but it’s rather difficult to craft the request and interpret the results properly.

Where the contract stops

The gRPC-Web roadmap recommends gRPC-Gateway instead. It can absolutely expose a web-friendly API from a gRPC service, but it changes where the Protobuf contract ends.

With gRPC-Web, one schema generates the server and browser clients, with a proxy translating the protocol in between:

BUILD TIME RUNTIME GRPC-WEB user.proto GENERATED CLIENT SERVER STUBS BROWSER grpc-web ENVOY grpc SERVER
GRPC-WEB BUILD TIME user.proto GENERATED CLIENT SERVER STUBS RUNTIME BROWSER grpc-web ENVOY grpc SERVER

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:

BUILD TIME RUNTIME GRPC-GATEWAY user.proto GATEWAY CODE SERVER STUBS generate / translate OPENAPI SCHEMA generates OPENAPI CODEGEN generates GENERATED CLIENT BROWSER json/http GRPC-GATEWAY grpc SERVER
GRPC-GATEWAY BUILD TIME user.proto GATEWAY CODE SERVER STUBS generate / translate OPENAPI SCHEMA generates OPENAPI CODEGEN generates GENERATED CLIENT RUNTIME BROWSER json/http GRPC-GATEWAY grpc SERVER

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:

CONNECT THE DATABASE IS DOWN STATUS ERROR HTTP/2 500 {"code":"internal"} API_GATEWAY error LOAD_BALANCER error ERROR_DASHBOARD error DEV_TOOLS error GENERATED_CLIENT error
CONNECT HTTP/2 500 {"code":"internal"} API_GATEWAY error LOAD_BALANCER error ERROR_DASHBOARD error DEV_TOOLS error GENERATED_CLIENT error

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 forgoes 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:

BUILD TIME RUNTIME CONNECT user.proto GENERATED CLIENT SERVER STUBS BROWSER connect SERVER
CONNECT BUILD TIME user.proto GENERATED CLIENT SERVER STUBS RUNTIME BROWSER connect SERVER

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.