When we hear about it, it sounds like it should work. gRPC runs on HTTP/2. Browsers speak HTTP/2. So the React app should be able to call the gRPC backend directly, right?

It can’t. And the reason isn’t a setting or a flag. It’s the protocol.

Why the browser can’t

Two reasons, and both are real.

First, trailers. gRPC sends its status code at the end of the response, in HTTP trailers, not the headers. grpc-status and grpc-message arrive after the body. That’s fine for a normal gRPC client, but the browser fetch and XMLHttpRequest APIs never expose trailers to your JavaScript. You literally cannot read the thing gRPC uses to tell you the call succeeded or failed.

Second, frame control. A real gRPC client reaches down to the HTTP/2 frame layer to manage streams. The browser doesn’t hand you that. fetch gives you a request and a response, not the framing underneath. So even setting trailers aside, you can’t drive the protocol the way gRPC expects.

So the browser is doing exactly what it’s designed to do. gRPC just needs a level of access browsers don’t give out.

So gRPC-Web exists

This is why gRPC-Web is a thing. It’s not “gRPC in the browser.” It’s a different protocol, designed to fit what a browser can actually do. The trick is: it packs the trailers into the response body, at the end, so JavaScript can read them with a normal response.

Here is a simple gRPC service:

syntax = "proto3";

package greet.v1;

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

service Greeter {
  rpc SayHello(HelloRequest) returns (HelloReply);
}

Our Python backend serves this over plain gRPC. But the browser can’t speak gRPC-Web to a gRPC server, the two protocols aren’t the same on the wire. Something in the middle has to translate.

The proxy you now run

To get from gRPC-Web (browser) to gRPC (your Python service), you run a proxy. The usual pick is Envoy with its grpc_web filter:

http_filters:
  - name: envoy.filters.http.grpc_web
  - name: envoy.filters.http.cors
  - name: envoy.filters.http.router

That’s the happy-path version. But now we have to run, deploy, monitor, and debug a proxy that wasn’t in our architecture. It’s another hop on every request and another thing that can break at 2am.

The build step, and what you give up

The browser also needs a generated client. You run protoc with the gRPC-Web plugin to turn that .proto into JavaScript or TypeScript, and call it from React:

import { GreeterClient } from "./generated/GreeterServiceClientPb";
import { HelloRequest } from "./generated/greeter_pb";

const client = new GreeterClient("https://api.example.com"); // the Envoy address

const req = new HelloRequest();
req.setName("Ada");

const reply = await client.sayHello(req, {});
console.log(reply.getMessage());

So now the frontend has a codegen step in its build, and the proto has to stay in sync between backend and frontend. But two things still bites:

  • No client streaming, no bidirectional streaming. gRPC-Web only does unary and server streaming. The two streaming modes that need the browser to push a stream up aren’t available. If you picked gRPC partly for bidi streaming, it doesn’t survive the trip to the browser.

  • You can’t read the wire. Open the Network tab and the payload is binary, not the JSON you’re used to. Debugging is grpcurl and generated stubs, not eyeballing a response.

The pragmatic alternative: a BFF

This older, dumber option just keeps working. Put a thin REST gateway in front, a backend-for-frontend, and let it do the translating in code you control:

# FastAPI BFF: REST for the browser, gRPC to your services.
@app.get("/api/greet")
def greet(name: str):
    with grpc.insecure_channel("greeter.internal:50051") as channel:
        stub = greeter_pb2_grpc.GreeterStub(channel)
        reply = stub.SayHello(greeter_pb2.HelloRequest(name=name))
    return {"message": reply.message}

The browser gets plain REST/JSON, with all the HTTP tooling, caching, and readable payloads that come with it. The services still talk gRPC behind the gateway. The cost is the mapping code, you write and maintain that /api/greet glue by hand. But there’s no proxy to run and nothing exotic in the browser.

Conclusion

Keep REST or a BFF at the browser edge and save gRPC for service-to-service behind it.