DEV Community

Leo
Leo

Posted on

What WebRTC Manual Signaling Actually Exchanges

Many WebRTC examples reduce connection setup to “exchange the SDP.” That is not enough when a connection fails.

A working connection involves at least three kinds of information:

  • Offer: what the initiator can send and receive.
  • Answer: which capabilities the receiver accepts.
  • ICE candidates: network paths the two peers might use.

WebRTC creates this data but does not deliver it to the other peer. Production apps normally use WebSocket or HTTP signaling. Here we will copy the signaling data by hand so every step stays visible.

Open the experiment

WebRTC manual signaling tester

Open the WebRTC manual signaling tester

Open it on two devices over HTTPS. Allow a camera or microphone, select Manual signaling (P2P) on both pages, and start capture.

Test on a home network or phone hotspot first. Corporate and campus networks often add restrictions that make the first experiment harder to diagnose.

Initiator: creating the Offer

The initiator creates an RTCPeerConnection and adds its local media tracks:

const peer = new RTCPeerConnection({ iceServers });

stream.getTracks().forEach((track) => {
  peer.addTrack(track, stream);
});

const offer = await peer.createOffer();
await peer.setLocalDescription(offer);
Enter fullscreen mode Exit fullscreen mode

createOffer() produces the media negotiation description. setLocalDescription() starts local ICE gathering.

That distinction matters. Immediately copying the SDP after setLocalDescription() can produce an incomplete signal because iceGatheringState may still be gathering. More candidates can arrive after the promise resolves.

Production systems usually use Trickle ICE and send each new candidate through the signaling service. Copy-and-paste signaling has no ongoing channel, so the tester uses non-trickle ICE:

  1. Set the local description.
  2. Wait for ICE gathering to complete, with a 10-second limit.
  3. Refuse to create a code unless the SDP contains at least one a=candidate: line.

The invitation is not raw SDP. The tester wraps it in a small envelope:

{
  "version": 1,
  "sessionId": "random ID for this exchange",
  "type": "offer",
  "description": {
    "type": "offer",
    "sdp": "..."
  },
  "createdAt": 0
}
Enter fullscreen mode Exit fullscreen mode

The JSON is compressed with Deflate and encoded as Base64 to make copying easier.

Base64 is not encryption. A signaling code can contain network candidate information, so do not paste real codes into public posts, issue trackers, or logs.

Receiver: creating the Answer

The receiver must apply the remote Offer before creating an Answer:

await peer.setRemoteDescription(remoteOffer);

const answer = await peer.createAnswer();
await peer.setLocalDescription(answer);
Enter fullscreen mode Exit fullscreen mode

setRemoteDescription() tells the browser what the initiator proposed. createAnswer() then describes what the receiver can accept.

The receiver also waits for ICE gathering, packages the Answer and candidates, and returns an answer code.

The initiator finishes signaling with:

await peer.setRemoteDescription(remoteAnswer);
Enter fullscreen mode Exit fullscreen mode

Its signalingState should move from have-local-offer back to stable.

The tester also checks that the invitation and answer have the same sessionId. This prevents an Answer from an older attempt from being applied to the current connection.

What the three copy-and-paste steps mean

Three-step WebRTC manual signaling flow

  1. Device A creates an invitation: Offer plus A's ICE candidates.
  2. Device B creates an answer code: apply the Offer, then create an Answer plus B's candidates.
  3. Device A applies the answer: apply the Answer and begin ICE connectivity checks.

Seeing remote video proves that a media track arrived, but it does not explain how the connection got there. These four states are more useful during diagnosis:

State What it tells you
signalingState Whether Offer and Answer were applied in a valid order
iceGatheringState Whether local candidate gathering is complete
iceConnectionState Whether ICE found a working candidate pair
connectionState The overall RTCPeerConnection state

The tester also calls getStats() once per second and displays inbound bitrate, frame rate, codec, packet loss, and round-trip time. These values are more useful than a single “connected” badge when the call works but performs badly.

What STUN can and cannot do

STUN helps a browser discover its public mapping and commonly produces a server-reflexive (srflx) candidate. It does not relay media.

If no candidate pair can connect—for example behind symmetric NAT, a strict corporate firewall, or a restricted UDP network—the peers need TURN. TURN provides a relay (relay) candidate and forwards the traffic.

So:

  • Having an Offer and Answer does not guarantee network connectivity.
  • Having ICE candidates does not guarantee that any pair will work.
  • If ICE remains in checking and then becomes failed, investigate TURN before rewriting the SDP exchange.

The tester includes a default STUN configuration but does not provide TURN credentials. Production TURN credentials should be short-lived; do not ship long-term usernames and passwords in frontend source.

Debug in this order

  1. Confirm that both pages captured at least one media source.
  2. Check that neither copied code was truncated, reformatted, or shortened with an ellipsis.
  3. Confirm that both Offer and Answer contain a=candidate: lines.
  4. Make sure the Answer belongs to the current invitation.
  5. Check whether signalingState returned to stable.
  6. See whether ICE is stuck in checking or has moved to failed.
  7. Test whether the network requires TURN or blocks UDP.

Manual signaling is useful for learning negotiation order, inspecting SDP, and separating signaling failures from network failures. A production application still needs reliable signaling, room and identity management, timeouts, retries, Trickle ICE, and TURN infrastructure.

Top comments (0)