gRPC-Web in Flutter: Why Browsers Can't Speak gRPC and What Proxies Do About It
GrpcWebClientChannel in a Flutter web app, the gRPC-Web protocol limits (unary and server-streaming only), Envoy grpc_web filter setup, CORS, and when to fall back to JSON.
Published on • November 13, 2026
AI Assistant

grpc-in-flutter showed the client side — generated stubs, ClientChannel, and a one-line swap to GrpcWebClientChannel.xhr for web. This post explains why that swap is necessary rather than cosmetic, what the browser-flavored protocol can and can’t do, and how the server-side proxy that makes it work is actually configured. Sources: the gRPC web platform docs, the gRPC-Web spec, and the grpc package (5.2.0) API.
Why the browser can’t do gRPC
Full gRPC rides on HTTP/2 with trailers carrying the final status code. Browsers expose neither capability to JavaScript: XHR and fetch speak HTTP/1.1 (or HTTP/2 without trailer access), and the browser sandbox simply doesn’t let a page write the trailing headers gRPC needs. gRPC-Web is the workaround: a protocol that encodes the same protobuf frames over HTTP/1.1 and moves the status into the body (or a trailer-emulation header) instead of HTTP trailers.
The Dart side is package:grpc’s web library:
import 'package:grpc/grpc_web.dart';
ClientChannel channelForWeb() => GrpcWebClientChannel.xhr(
Uri.parse('https://api.example.com'),
);
final client = OrderServiceClient(channelForWeb());
GrpcWebClientChannel.xhr is the constructor the current API exposes (an XMLHttpRequest-based transport — note there’s no .fetch variant in 5.2.0), and it plugs into the same generated clients, CallOptions metadata, deadlines, and GrpcError status codes you use on mobile. Your app can branch once at startup:
final channel = kIsWeb ? channelForWeb() : ClientChannel('api.example.com',
port: 443, credentials: const ChannelCredentials.secure());
The two hard protocol limits
gRPC-Web is not gRPC with a different transport — it’s a subset:
- Unary and server-streaming only. Client-streaming and bidirectional streaming are unsupported: the browser sends one request message (or a pre-buffered sequence) and reads a stream of responses. If your API leans on bidi — live cursors, chat — you need a different transport for web (WebSocket, SSE) or a redesign of that endpoint.
- No HTTP/2 flow control or trailers. Status arrives via the
grpc-statusheader at the end of the body. Deadlines still work (they map to timeouts), but anything that depended on trailer metadata needs rethinking.
The proxy: where gRPC-Web becomes gRPC
No gRPC server speaks gRPC-Web natively — a proxy translates. The reference implementation is Envoy, which the gRPC web docs position in front of your service:
static_resources:
listeners:
- name: grpc_web
address: { socket_address: { address: 0.0.0.0, port_value: 8080 } }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
codec_type: AUTO
stat_prefix: ingress_http
route_config: { name: local_route, virtual_hosts: [{ name: backend, domains: ["*"], routes: [{ match: { prefix: "/" }, route: { cluster: grpc_backend }] }] }]
http_filters:
- name: envoy.filters.http.grpc_web
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
- name: envoy.filters.http.cors
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
- name: envoy.filters.http.router
typed_config: { "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router }
clusters:
- name: grpc_backend
connect_timeout: 0.25s
type: LOGICAL_DNS
lb_policy: ROUND_ROBIN
http2_protocol_options: {}
load_assignment: { cluster_name: grpc_backend, endpoints: [{ lb_endpoints: [{ endpoint: { address: { socket_address: { address: backend, port_value: 9000 } } } }] }] }
The envoy.filters.http.grpc_web filter rewrites the request into proper gRPC for the upstream (http2_protocol_options on the cluster), and envoy.filters.http.transcoder can additionally bridge JSON/REST if you have non-gRPC consumers. Smaller deployments sometimes use the standalone grpcwebproxy instead; either way, the Flutter app only ever talks to the proxy’s origin.
Verify the proxy before you blame Flutter
The translation layer is where most breakage lives, and you can test it without a browser:
# gRPC-Web framing over plain HTTP — should return grpc-status: 0
curl -sS -X POST https://api.example.com/orderpb.OrderService/GetOrder \
-H 'content-type: application/grpc-web+proto' \
-H 'x-grpc-web: 1' \
--data-binary $'\x00\x00\x00\x00\x00' # empty framed message
# Same endpoint through the proxy as real gRPC (control experiment)
grpcurl -plaintext api.example.com:8080 list
If curl works but the Flutter web app doesn’t, the delta is CORS — check the preflight response for access-control-allow-headers covering x-grpc-web, content-type, and x-user-agent. If curl itself fails with 404, the grpc_web filter isn’t in the Envoy chain or the route isn’t matching; if it fails with a connection error to the backend, the cluster’s http2_protocol_options are missing.
CORS is part of the protocol
The browser will preflight your RPC. The proxy must allow the headers gRPC-Web sets — x-grpc-web, x-user-agent, content-type: application/grpc-web+proto — and return Access-Control-Allow-Origin for your app’s origin. Envoy’s CORS filter or a thin nginx layer in front handles this; forgetting it produces the classic “works with curl, fails only in the browser” bug.
When to skip gRPC-Web entirely
Three honest reasons teams fall back to JSON/REST for the web target: the API needs client or bidi streaming; the team can’t run a translating proxy (serverless hosts, managed API gateways without gRPC-Web support); or the endpoint is a public, cacheable resource where HTTP semantics beat RPC. The hybrid pattern — gRPC mobile ↔ proxy, REST web ↔ same backend — is common and perfectly respectable; generate your Dart clients once and treat the web channel as an implementation detail behind a repository interface, so the swap stays one line.
Takeaway: Browsers can’t speak HTTP/2 gRPC or trailers, so Flutter web uses GrpcWebClientChannel.xhr against an Envoy-style proxy that transcodes to real gRPC — accept the unary/server-streaming-only limit, configure CORS for x-grpc-web, and drop to JSON where streaming direction or hosting makes the proxy not worth it.