Implementing Custom Signaling Protocols with gRPC-Web for WebRTC
Replacing JSON-over-WebSocket with type-safe, bidirectional gRPC-Web streams eliminates ad-hoc parsing and enforces strict framing for SDP and ICE payloads. This guide is part of the Signaling State Machine Patterns section, and it covers the exact decision of when a schema-driven RPC transport earns its tooling cost over a WebSocket β and how to wire one to RTCPeerConnection without breaking the ordering rules described in ICE Candidate Trickle vs Bulk Gathering.
Context & Trade-offs
WebSocket signaling is the default for good reason: it delivers SDP and ICE candidates in sub-10 ms over a single persistent socket with almost no setup. gRPC-Web does not beat that latency β both ride one HTTP/2 (or WebSocket-tunnelled) connection β so you do not adopt it for speed. You adopt it for a contract. A Protobuf schema makes Offer, Answer, Candidate, and Bye distinct, versioned message types, so a malformed payload fails at deserialisation instead of three frames later as an InvalidStateError. That matters most on teams shipping multiple client platforms against one signaling backend, where an untyped JSON envelope drifts silently between releases.
The cost is real: gRPC-Web cannot speak raw HTTP/2 from a browser, so you need a translating proxy β Envoy with the grpc_web and cors filters, or grpc-gateway/improbable-eng middleware in a Go or Node backend. That proxy adds an operational hop, a keepalive to tune, and binary-framing config that, if wrong, breaks every stream. Generated stubs add a build step. For a single-platform app with a stable message set, that overhead is not worth it and a typed WebSocket envelope (e.g. zod-validated JSON) gets you most of the safety. Reserve gRPC-Web for multi-client, multiplexed deployments where the schema pays for itself.
| Factor | JSON over WebSocket | gRPC-Web |
|---|---|---|
| Message delivery | sub-10 ms | sub-10 ms (proxy hop adds <1 ms) |
| Type safety | runtime validation only | compile-time from .proto |
| Infra required | a WebSocket endpoint | Envoy/gateway proxy + keepalive |
| Best fit | single client, fast iteration | many clients, strict contract |
Why a browser cannot speak gRPC directly
The proxy is not gratuitous. Native gRPC terminates every call with HTTP/2 trailers β a second header block carrying grpc-status and grpc-message after the body β and no browser API exposes trailers or lets you write raw HTTP/2 DATA frames. gRPC-Web works around this by moving the trailers into the body: each frame is prefixed with a 1-byte flag plus a 4-byte big-endian length, and the final frame sets the flagβs high bit (0x80) to mark βthis payload is the trailer block, not a messageβ. The proxyβs whole job is translating that framing back into real HTTP/2 trailers for the upstream server, which is why a misconfigured filter chain breaks every stream rather than degrading gracefully.
That framing also constrains what βbidirectionalβ can mean. The reference grpc-web JS client supports unary and server-streaming calls only, because the request body must be complete before the browser sends it. Full-duplex is available through @improbable-eng/grpc-web with its WebSocket transport β which tunnels gRPC framing inside a WebSocket, so you are back on the transport you were replacing, now with a schema β or through Chromeβs fetch upload streaming (duplex: 'half', Chrome 105+, over HTTP/2 only), which Firefox and Safari still do not implement. Plan for the half-duplex shape unless you control the browser matrix.
One more sizing note: the grpc-web-text mode base64-encodes every frame, inflating a 2β4 KB SDP offer by roughly 33% and forcing an extra decode per trickled candidate. Use binary application/grpc-web+proto in production and reserve text mode for environments where an intermediary rewrites binary bodies.
Minimal Runnable Implementation
Define the contract with a oneof so SDP, ICE, and error payloads cannot collide during rapid candidate generation, then attach the bidirectional stream to the peer connection. Map inbound sdp to setRemoteDescription, inbound candidate to addIceCandidate, and buffer candidates until the remote description resolves β the same buffering rule the FSM enforces.
// signaling.proto β one message type, payloads mutually exclusive via oneof
syntax = "proto3";
package webrtc.signaling;
message SignalingMessage {
string peer_id = 1;
oneof payload {
string sdp = 2; // JSON-encoded RTCSessionDescriptionInit
string candidate = 3; // JSON-encoded RTCIceCandidateInit
string error = 4;
}
}
service SignalingService {
// Full-duplex stream: client and server both write SDP/ICE as discovered
rpc ExchangeSignals (stream SignalingMessage) returns (stream SignalingMessage) {}
}
oneof makes SDP, ICE and error payloads mutually exclusive at the byte level, so collisions fail at decode time.// Client: bind the gRPC-Web stream to the RTCPeerConnection lifecycle
import { SignalingServiceClient } from './generated/signaling_grpc_web_pb';
import { SignalingMessage } from './generated/signaling_pb';
const client = new SignalingServiceClient('https://grpc-proxy.example.com');
const call = client.exchangeSignals(); // bidirectional stream handle
const pendingCandidates = []; // hold until remote description set
pc.onicecandidate = (e) => {
if (!e.candidate) return; // null candidate = gathering done
const msg = new SignalingMessage();
msg.setPeerId(remotePeerId);
msg.setCandidate(JSON.stringify(e.candidate.toJSON()));
call.write(msg); // trickle each candidate immediately
};
pc.onnegotiationneeded = async () => {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer); // stable -> have-local-offer
const msg = new SignalingMessage();
msg.setPeerId(remotePeerId);
msg.setSdp(JSON.stringify({ type: offer.type, sdp: offer.sdp }));
call.write(msg);
};
call.on('data', async (msg) => {
if (msg.hasSdp()) {
const init = JSON.parse(msg.getSdp());
await pc.setRemoteDescription(init); // now flush buffered candidates
while (pendingCandidates.length) await pc.addIceCandidate(pendingCandidates.shift());
} else if (msg.hasCandidate()) {
const init = JSON.parse(msg.getCandidate());
if (pc.remoteDescription) await pc.addIceCandidate(init);
else pendingCandidates.push(init); // buffer: avoids InvalidStateError
}
});
On the server, keep an in-memory registry mapping peer_id to its active stream and forward messages atomically so payloads never interleave. That registry is single-node state, so the moment you run two backend replicas you need the same fan-out broker described in Scaling WebSocket Signaling with Redis Pub/Sub β the transport changes, the routing problem does not. Apply a bounded queue per stream to absorb the burst of a trickle-ICE phase β an unbuffered stream can exhaust the heap under rapid candidate writes β and explicitly close the stream when the clientβs connectionState reaches closed to avoid zombie sessions.
Half-duplex fallback: one stream down, unary calls up
When you cannot take the WebSocket-transport dependency, split ExchangeSignals into two RPCs: a server-streaming Subscribe that the client opens once and holds open for the session, and a unary Send invoked per outbound message. The downlink keeps its ordering guarantee β a single stream is still a single ordered sequence β while the uplink becomes a series of independent POSTs. The tax is one request per trickled candidate; a typical host/srflx/relay gathering round produces 8β20 candidates, so that is 8β20 short-lived requests over an already-open HTTP/2 connection, adding well under 1 ms each and no new sockets.
// Half-duplex shape: server-streaming downlink + unary uplink
const sub = client.subscribe(new SubscribeRequest().setPeerId(localPeerId));
sub.on('data', (msg) => handleInbound(msg)); // ordered downlink, one stream
sub.on('error', (err) => scheduleResubscribe(err)); // reopen with backoff, see below
let sendChain = Promise.resolve(); // serialise uplink writes
function send(msg) {
// Unary calls race by default: HTTP/2 gives no cross-request ordering,
// so chain them or an ICE candidate can land before its own offer.
sendChain = sendChain.then(() => new Promise((resolve, reject) => {
client.send(msg, {}, (err) => (err ? reject(err) : resolve()));
}));
return sendChain;
}
The serialisation matters more than it looks. Two concurrent unary calls on one HTTP/2 connection are separate streams with no ordering relationship, so a candidate emitted 3 ms after setLocalDescription() can reach the server before the offer it belongs to and be dropped as orphaned. Chaining the promises costs nothing in a gathering phase that is already spread over hundreds of milliseconds, and it preserves the trickle-ICE contract that keeps time-to-first-frame in the 200β800 ms band instead of the 2β4 s a bulk exchange costs.
Reproduction Steps & Debugging Log Patterns
- Launch Envoy with
envoy.filters.http.grpc_webandenvoy.filters.http.corsenabled andhttp2_protocol_options.connection_keepaliveset; without keepalive, idle streams die mid-negotiation. - Open two browser clients and trigger an offer from the initiator.
- Watch the browser console and Envoy access logs; confirm the offer reaches the second client and an answer returns.
- Kill the network on one client for 5 s to force a
disconnected/ICE-restart cycle β the media-side recovery path is covered in Triggering an ICE Restart Without Dropping Media β and confirm the stream survives or reconnects. - Correlate any failure against the patterns below.
Expected and diagnostic log lines:
# Healthy: offer out, answer back, ICE connected
[client-A] negotiationneeded -> setLocalDescription(offer) ok
[client-B] data: sdp -> setRemoteDescription ok -> flushed 4 candidates
[client-A] connectionState: connected
# Proxy timeout / missing keepalive
[gRPC] UNAVAILABLE: stream terminated by RST_STREAM -> set connection_keepalive in Envoy
# State-machine violation: out-of-order SDP
DOMException: Failed to set remote answer sdp: Called in wrong state: stable
-> verify candidate buffering and single-offer serialisation
# Framing/CORS misconfig
Failed to execute 'send' on 'XMLHttpRequest': the object's state is DONE
-> enable grpc_web + cors HTTP filters and binary framing in the proxy
Each of those four lines has exactly one first-choice fix, so triage by the string the log emits rather than by guessing at the layer:
A fifth signature has no error string at all, which is what makes it expensive to find: batched trickle. Candidates arrive at the remote peer in one clump at the end of gathering instead of individually, and connect time regresses toward the 2β4 s of a bulk exchange even though both ends are trickling correctly. The cause is a response-buffering intermediary β an nginx or CDN hop in front of Envoy that accumulates the server stream until the body completes. Diagnose it by timestamping each data event on the client: healthy trickle shows candidates landing tens to hundreds of milliseconds apart, buffered trickle shows all of them within the same millisecond. Fix it by disabling response buffering on every hop between the browser and the proxy, not just the last one.
; Signaling proxy tunables β apply on every hop, not only the terminating one
grpc_web_content_type = application/grpc-web+proto ; binary, not the 33%-larger text mode
response_buffering = off ; else trickled candidates arrive batched
keepalive_interval = 25s ; under the <30s NAT/CGNAT binding refresh
keepalive_timeout = 10s ; declare the stream dead, then resubscribe
stream_idle_timeout = 0 ; a signaling stream is idle by design
max_concurrent_streams = 256 ; per connection; unary uplink burns these
resubscribe_backoff = 3s..5s ; jittered, matching signaling fallback timeouts
resubscribe_max_retries = 3 ; same ceiling as ICE restart attempts
The keepalive value is the one people get wrong. Set it above 30 s and mobile or carrier-NAT paths will silently reap the mapping while the stream looks alive to both endpoints β the connection is only discovered dead when an offer fails to arrive, which is precisely the moment you cannot afford a 3β5 s recovery. A 25 s ping sits under the sub-30 s refresh window that mobile and CGNAT paths demand, and costs a handful of bytes per interval.
Common Implementation Mistakes
- Omitting Envoy CORS or binary framing. Browsers cannot deserialise the Protobuf frames and every
sendfails with anXMLHttpRequeststate error; enable both HTTP filters before anything else. - Calling
addIceCandidate()beforesetRemoteDescription()resolves. ThrowsInvalidStateError; buffer candidates and flush after the remote description is applied, exactly as the Signaling State Machine Patterns guide prescribes. - No stream backpressure. A rapid trickle phase floods an unbounded server queue and exhausts the heap; cap the per-stream queue.
- Ignoring gRPC keepalive. Long negotiation windows silently drop the stream with
RST_STREAM; configureconnection_keepalive. - Skipping stream teardown on
connectionState === 'closed'. Leaks streams and accumulates zombie sessions on the server; pair teardown with an explicit resume path so a dropped stream rejoins its room, as covered in Reconnecting Signaling Sockets Without Losing Session State. - Firing unary uplink calls concurrently. HTTP/2 gives independent requests no ordering, so a candidate overtakes its own offer and the server discards it against an unknown session; chain the calls or stamp each message with a monotonically increasing sequence number the server can reorder on.
- Treating the SDP blob as permanently opaque. Shipping SDP as a JSON string inside the message is the right first move, but it means the schema cannot catch the m-line ordering and direction bugs described in Debugging SDP m-line Mismatches; log the parsed
m=section list alongside every stored offer so the mismatch is visible server-side. - Reconnecting without jitter. A proxy restart drops every stream at once and a fixed 3 s retry brings the whole room back in lockstep, stampeding the backend; randomise the 3β5 s window per client and cap retries at 3 before surfacing a failure to the user.
FAQ
Can gRPC-Web fully replace WebSocket for WebRTC signaling?
Yes, with caveats. It gives type-safe bidirectional streaming for SDP and ICE, but it requires a translating proxy and explicit stream lifecycle management. The Protobuf schema buys stronger guarantees than JSON at the cost of tooling β worth it for multi-platform backends, overkill for a single fast-iterating client.
How do I keep ICE candidate ordering correct over a stream?
Buffer inbound candidates in an array and flush them sequentially only after setRemoteDescription() resolves. This preserves trickle-ICE semantics and avoids InvalidStateError, identical to the buffering used over WebSocket.
Does Protobuf actually make signaling messages smaller?
Barely, and that is not the reason to use it. The dominant payload is the SDP body β 2β4 KB of text that stays text whichever encoding wraps it β so Protobuf only compacts the envelope: field tags and varint lengths instead of JSON keys and quotes, saving perhaps 40β80 bytes per message. A trickled candidate is the better case, dropping from roughly 250 bytes of JSON to around 200, but at 8β20 candidates per negotiation that is a few kilobytes across an entire session. Choose the schema for the compile-time contract and the generated multi-language clients; treat the byte savings as rounding error.
Should TURN credentials travel over the same stream?
Yes β issuing them in the first server message avoids a separate authenticated fetch on the critical path, and the schema gives you a typed IceServers message instead of a hand-rolled JSON blob whose field names drift per client. Make them short-lived rather than static: derive them per session as described in Time-Limited TURN Credentials with HMAC, and re-issue on the same stream before expiry so a long call surviving an ICE restart still has valid relay credentials to gather with.
Related: return to the Signaling State Machine Patterns guide, handle simultaneous offers over any transport in Recovering from Glare in Offer Collisions, and review the underlying transport in the WebSocket Signaling Implementation guide.