gRPC streaming vs WebSockets #
Your backend services already talk gRPC, with Protocol Buffers contracts and generated clients in four languages. Now the web frontend needs a live stream of the same data, and the question is whether to extend gRPC to the browser or put a WebSocket gateway in front. On paper, gRPC’s bidirectional streaming does everything a WebSocket does, with typed messages and HTTP/2 multiplexing on top. In practice, browsers cannot speak native gRPC, and the workarounds constrain what “streaming” means. The right answer is often both: gRPC between services, WebSockets (or a gRPC-Web variant) at the edge — and knowing exactly where each one’s limits are is what makes that split clean.
Root cause #
gRPC runs over HTTP/2 and relies on features browsers do not expose to JavaScript: direct control of HTTP/2 frames and, crucially, HTTP trailers, which gRPC uses to deliver the final status of a call. The fetch API cannot read trailers, and it cannot perform the full-duplex streaming that native gRPC uses. So a browser cannot be a native gRPC client.
The browser-compatible variants work around this. gRPC-Web encodes trailers into the response body and needs a translating proxy (Envoy, or a server that speaks it natively). Connect (from Buf) speaks gRPC, gRPC-Web and its own simpler HTTP protocol from the same handlers. Both support unary calls and server streaming in browsers well. Neither supports true bidirectional or client streaming from browsers today, because fetch request-body streaming requires HTTP/2 or HTTP/3 and half-duplex semantics that are not uniformly available. WebSockets, by contrast, are full-duplex in every browser.
Resolution #
For most architectures, the dividing line is the browser. Keep gRPC for service-to-service communication, where native bidirectional streaming, deadlines and generated clients shine. At the edge, pick by what the browser needs to do:
- Browser only receives a stream (dashboards, feeds, notifications): server-streaming gRPC-Web or Connect works well and keeps Protobuf contracts end to end.
- Browser sends and receives continuously (chat, collaboration, games, live controls): use a WebSocket gateway that bridges to gRPC streams behind it.
The gateway below terminates WebSockets from browsers and opens one bidirectional gRPC stream per client to an internal service, translating in both directions. Protobuf messages cross the WebSocket as binary frames, so the contract stays typed end to end.
import { WebSocketServer, WebSocket } from 'ws';
import * as grpc from '@grpc/grpc-js';
import { RoomServiceClient } from './gen/room_grpc_pb'; // generated from room.proto
import { ClientEvent, ServerEvent } from './gen/room_pb';
const GRPC_TARGET = 'room-service.internal:50051';
const STREAM_DEADLINE_MS = 6 * 60 * 60 * 1000; // long-lived, but not unbounded
const rooms = new RoomServiceClient(GRPC_TARGET, grpc.credentials.createInsecure(), {
'grpc.keepalive_time_ms': 20_000, // keep the HTTP/2 connection alive
'grpc.keepalive_permit_without_calls': 1,
});
const wss = new WebSocketServer({ port: 8080, maxPayload: 256 * 1024 });
wss.on('connection', (ws: WebSocket, req) => {
ws.binaryType = 'nodebuffer';
const md = new grpc.Metadata();
md.set('x-user-id', String((req as any).userId)); // identity from the upgrade auth
const call = rooms.session(md, { deadline: Date.now() + STREAM_DEADLINE_MS });
// gRPC → browser: forward each server event as one binary frame.
call.on('data', (ev: ServerEvent) => {
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 1_000_000) ws.send(ev.serializeBinary());
});
call.on('error', (err: grpc.ServiceError) => ws.close(err.code === grpc.status.UNAVAILABLE ? 1013 : 1011, err.details.slice(0, 100)));
call.on('end', () => ws.close(1000, 'stream ended'));
// Browser → gRPC: validate by decoding, then write to the stream.
ws.on('message', (data: Buffer) => {
try { call.write(ClientEvent.deserializeBinary(new Uint8Array(data))); }
catch { ws.close(1007, 'invalid payload'); } // not a valid ClientEvent
});
ws.on('close', () => call.end()); // half-close the gRPC stream
});
Error mapping is the part worth thinking about: gRPC status codes carry meaning the browser should act on. UNAVAILABLE maps naturally to WebSocket 1013 Try Again Later, which well-behaved clients retry with backoff; other errors map to 1011. The binary framing in the browser uses the same generated Protobuf classes, as described in Protobuf over WebSockets.
Edge cases #
Load balancing gRPC streams. gRPC multiplexes calls over long-lived HTTP/2 connections, so connection-level (L4) load balancers pin all of a gateway’s streams to whichever backend its connection landed on. Use an L7 balancer that understands HTTP/2 streams, or client-side balancing with multiple subchannels, or streams will pile up unevenly.
Deadlines and long-lived streams. gRPC best practice sets a deadline on every call, while real-time sessions last hours. Set a long but finite deadline and let clients reconnect when it expires, rather than disabling deadlines; that also rebalances load periodically.
Proxies and HTTP/2. Corporate proxies that break WebSockets often break HTTP/2 too, or downgrade it to HTTP/1.1, which gRPC-Web tolerates and native gRPC does not. That is another reason native gRPC stays inside your network.
Verification #
Check the edge first: in the browser’s Network panel, the WS connection should carry binary frames whose sizes match the Protobuf encoding of your messages. Then verify the backend: grpcurl can open the same stream directly to confirm the service behaves as the gateway expects.
# Exercise the bidi stream without the gateway (reflection enabled on the service).
grpcurl -plaintext -H 'x-user-id: test' -d @ room-service.internal:50051 room.RoomService/Session <<'EOF'
{ "join": { "roomId": "r1" } }
EOF
Load-test the gateway with the concurrency you expect and watch backend stream counts per instance; an uneven distribution points at L4 balancing of HTTP/2 connections.
Operational checklist #
FAQ #
Can browsers use gRPC bidirectional streaming? #
Not natively today. Browsers cannot read HTTP trailers or perform full-duplex HTTP/2 streaming from JavaScript, so gRPC-Web and Connect support unary and server-streaming calls only. For bidirectional streaming from a browser, use WebSockets or WebTransport.
Is gRPC faster than WebSockets? #
For service-to-service traffic, gRPC’s HTTP/2 multiplexing and Protobuf encoding are very efficient. For browser traffic the comparison is really Protobuf versus JSON: sending Protobuf over a WebSocket gives the same encoding efficiency.
Should I use Connect instead of gRPC-Web? #
Connect serves gRPC, gRPC-Web and its own HTTP protocol from the same handlers and does not require a translating proxy, which simplifies deployment. Its browser streaming limits are the same as gRPC-Web’s.
Does WebTransport change this? #
WebTransport gives browsers full-duplex streams over HTTP/3, which could eventually carry gRPC-style bidirectional streams. Support and tooling are still maturing; see WebTransport vs WebSocket.
Related #
- Protobuf over WebSockets — typed binary frames at the edge.
- When to Use WebSockets over Server-Sent Events — another receive-only option.
- WebTransport vs WebSocket — the next-generation full-duplex transport.
- Envoy WebSocket Proxy Configuration — the proxy that also translates gRPC-Web.
Back to WebSocket vs SSE vs WebRTC.