The same argument comes up over and over on Twitter, LinkedIn, Reddit, Hacker News, and anywhere else developers gather: gRPC vs. REST. Where should you use HTTP/JSON APIs (what most people mean by “REST”) and where should you use gRPC? The same advice is almost always given: use REST for public or browser-facing APIs, and gRPC for microservices and infrastructure such as etcd and Kubernetes.
But… why? Most of the explanations people give are accurate. An HTTP API using application/json as the content type works with a browser without any extra tooling, and every proxy, CDN, and debugger already knows how to handle APIs shaped this way. With gRPC you get a schema-driven workflow with generated clients, plus compact binary messages and streaming RPCs. Each one has its strengths, but why can’t we use the strengths of both?
This is why “gRPC vs REST” is the wrong question. Connect lets browsers call the same service as your gRPC clients, using HTTP and JSON.
Standard gRPC needs a proxy for browsers
gRPC relies on HTTP/2 features that browser APIs don’t expose to JavaScript, including trailers. So standard gRPC can’t be called directly from browser JavaScript.
gRPC-Web tried to bridge the gap by encoding trailers in a separate frame inside the body of the HTTP response. The protocol is still spoken by other implementations, Connect included, but the official grpc/grpc-web project ultimately failed. Its own roadmap says it can no longer deliver new modern solutions, does not plan to add new features, and recommends gRPC-Gateway instead. An earlier post, gRPC-Web Failed the Web, covers the reasons in detail.
gRPC-Gateway and Envoy let a browser call your gRPC service through an HTTP/JSON API. Both tools support default routes. Custom paths and HTTP methods go in the proto file as google.api.http annotations.
To test a browser call in this setup, you need the gateway running as well as your application. Custom mappings add some API design work too. A route like GET /users/{id} might be exactly what you want, but it’s not worth adding just to call an existing GetUser RPC from JavaScript.
Connect doesn’t need a proxy
A Connect server speaks gRPC, gRPC-Web, and Connect on the same port. A browser can send a Connect request with JSON or binary Protobuf to the same URL a gRPC client uses. The server takes care of decoding the request before passing it to your handler. Only one server implementation is needed for all three protocols:
curl should just work
A dead-simple requirement for any API is the “send a coworker a curl command” test. This should be trivial. It is trivial with REST APIs, but standard gRPC and gRPC-Web fail this test spectacularly.
You can do it with curl if you build the frame by hand. Standard gRPC and gRPC-Web use the same framing for data messages, so here’s the gRPC-Web version, which doesn’t need HTTP/2:
printf '\x00\x00\x00\x00\x1c{"sentence":"I feel happy."}' | curl -sS --data-binary @- \
-H 'Content-Type: application/grpc-web+json' \
https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say | xxdThose first five bytes are the message prefix: one flag byte, then a four-byte big-endian length, 00 00 00 1c for the 28 bytes of JSON that follow. The response comes back framed the same way, with the status buried in the body. This is only barely intelligible using the xxd command:
00000000: 0000 0000 277b 2273 656e 7465 6e63 6522 ....'{"sentence"
00000010: 3a22 446f 2079 6f75 206f 6674 656e 2066 :"Do you often f
00000020: 6565 6c20 6861 7070 793f 227d 8000 0000 eel happy?"}....
00000030: 2067 7270 632d 6d65 7373 6167 653a 200d grpc-message: .
00000040: 0a67 7270 632d 7374 6174 7573 3a20 300d .grpc-status: 0.
00000050: 0a .Change I feel happy. to a longer sentence and you have to recount the bytes and update the prefix. And this is gRPC-Web’s best case: the JSON encoding works here because the demo is a Connect server, which has a JSON codec for every protocol it speaks. The protocol allows it, but the official gRPC-Web client only sends Protobuf, and gRPC-Go registers only the Protobuf codec by default.
Tools like grpcurl and buf curl handle the encoding for you. They need access to the schema, which you can supply yourself or retrieve from the server through reflection.
Connect is plain HTTP
For a unary Connect call, you can send JSON as the POST body with Content-Type: application/json. Here’s a request to the same method that we called above:
curl -X POST \
https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say \
-H "Connect-Protocol-Version: 1" \
-H "Content-Type: application/json" \
-d '{"sentence":"Hello"}'You can run that command against the live demo and read the JSON response directly in your terminal.
Add -i to the curl command to see the HTTP status too. If a unary Connect handler returns NotFound, you’ll get a 404. gRPC would return HTTP 200 for the same error, with the failure recorded in the grpc-status trailer.
If a unary method has no side effects, Connect lets you call it with GET. Add this option to the method and regenerate the server code:
rpc Say(SayRequest) returns (SayResponse) {
option idempotency_level = NO_SIDE_EFFECTS;
}Then call it with GET, with the request in the query string:
curl "https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say?message=%7B%22sentence%22%3A%22Hello%22%7D&encoding=json&connect=v1"Since the whole request is in the URL, normal HTTP caching works. Set a Cache-Control header and CDNs and browsers can cache it like any other GET.
Keep the gRPC features
gRPC’s side of the argument is the schema-driven workflow: generated clients, compact binary messages, and streaming. Connect keeps all of it, in the browser and for every other client. Here’s the Say method from Eliza, along with its request and response messages:
syntax = "proto3";
package connectrpc.eliza.v1;
service ElizaService {
rpc Say(SayRequest) returns (SayResponse) {
option idempotency_level = NO_SIDE_EFFECTS;
}
}
message SayRequest {
string sentence = 1;
}
message SayResponse {
string sentence = 1;
}With Protobuf-ES configured in buf.gen.yaml, run buf generate to generate the definitions used by the browser client:
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { ElizaService } from "./gen/connectrpc/eliza/v1/eliza_pb";
const client = createClient(
ElizaService,
createConnectTransport({ baseUrl: "https://demo.connectrpc.com" }),
);
const { sentence } = await client.say({ sentence: "Hello" });client.say({ sentence: 123 }) won’t pass the TypeScript check because the proto defines sentence as a string. The valid call above sends {"sentence":"Hello"}, just like the curl command. The client handles building the HTTP request and decoding the response. Set useBinaryFormat: true on the transport and it sends binary Protobuf instead, with no other change to the code.
Streaming comes from the same generated client. Eliza’s Introduce method is a server-streaming RPC, and it shows up as an async iterable:
for await (const response of client.introduce({ name: "Kevin" })) {
console.log(response.sentence);
}Browsers can’t stream a request body through fetch, so client and bidi streaming stay backend-only. That limit comes from the browser, not from Connect. Clients outside the browser get all four RPC types.
The schema also catches breaking changes before any client sees them. buf breaking compares the proto against the last released version and fails on a removed field or a changed type. Since the browser client and the gRPC clients are generated from the same file, one check covers all of them.
You don’t have to choose
A service shouldn’t need a gateway in front of it just because somebody needs to call it from a browser. With Connect, the browser client is generated from the proto and calls the service directly, and the server stays compatible with gRPC.