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.

Signaling transport stacks compared layer by layerTwo vertical stacks. The JSON over WebSocket stack has app code, hand-rolled validation, a text frame, TLS over TCP and the signaling server, with no proxy layer. The gRPC-Web stack replaces validation with generated proto stubs, uses a binary frame, and inserts an Envoy grpc_web and cors hop before the gRPC signaling server.JSON over WebSocketgRPC-WebBrowser app codeHand-rolled JSON validationText WebSocket frameTLS 1.3 / TCPno translating hopSignaling serverBrowser app codeGenerated stubs from .protoBinary gRPC-Web frameTLS 1.3 / TCPEnvoy: grpc_web + cors filtersgRPC signaling serverBoth deliver SDP and ICE in sub-10 ms; the proxy hop adds under 1 ms and one more thing to operate.
The only structural difference is the translating proxy β€” you pay an extra hop for a compile-time contract, not for latency.
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) {}
}
SignalingMessage on the gRPC-Web wireA five-byte gRPC-Web header made of a one-byte flag and a four-byte big-endian length precedes the SignalingMessage body. The body expands into field one, peer_id, followed by a oneof payload whose three alternatives are sdp with tag 0x12, candidate with tag 0x1A and error with tag 0x22, exactly one of which appears on the wire.One frame on the wire1 B flag0x004 B lengthbig-endianN B protobuf bodySignalingMessage1 Β· peer_id (tag 0x0A)oneof payload β€” exactly one field is present2 Β· sdp (0x12) β€” RTCSessionDescriptionInit JSON3 Β· candidate (0x1A) β€” RTCIceCandidateInit JSON4 Β· error (0x22) β€” diagnostic stringEvery message carries thesame 5-byte header. Only theoneof tag changes between anoffer and a trickled candidate,so a stray second payload is adeserialisation failure, not anInvalidStateError three frames on.
The 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

  1. Launch Envoy with envoy.filters.http.grpc_web and envoy.filters.http.cors enabled and http2_protocol_options.connection_keepalive set; without keepalive, idle streams die mid-negotiation.
  2. Open two browser clients and trigger an offer from the initiator.
  3. Watch the browser console and Envoy access logs; confirm the offer reaches the second client and an answer returns.
  4. 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.
  5. 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:

Triage tree from log line to fixA stream failure during negotiation branches into four observed symptoms: an UNAVAILABLE RST_STREAM error, an XMLHttpRequest DONE state error, a wrong-state SDP exception, and heap growth during the trickle burst. Each maps to one fix: Envoy keepalive, the grpc_web and cors filters, candidate buffering with a single in-flight offer, and a bounded per-stream queue. All four converge on re-running the reproduction steps.Stream fails mid-negotiationUNAVAILABLE: streamterminated byRST_STREAMsend() fails:XMLHttpRequeststate is DONECalled in wrongstate: stableon remote answerServer heap climbsduring the trickleburstSet Envoyconnection_keepaliveEnable grpc_web +cors HTTP filtersBuffer candidates,one offer in flightBound the per-stream write queueRe-run steps 1–4 β€” expect: setRemoteDescription ok,flushed N candidates, connectionState: connected
Triage by the emitted string: each of the four failure signatures has one first-choice fix, and all four end at the same verification.

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

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.