<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Leo</title>
    <description>The latest articles on DEV Community by Leo (@leoluo).</description>
    <link>https://dev.to/leoluo</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4048545%2Faac2c6ca-9cd2-451f-bfae-368286dd2224.png</url>
      <title>DEV Community: Leo</title>
      <link>https://dev.to/leoluo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/leoluo"/>
    <language>en</language>
    <item>
      <title>What WebRTC Manual Signaling Actually Exchanges</title>
      <dc:creator>Leo</dc:creator>
      <pubDate>Mon, 27 Jul 2026 02:04:43 +0000</pubDate>
      <link>https://dev.to/leoluo/connect-two-webrtc-peers-with-manual-signaling-1o2h</link>
      <guid>https://dev.to/leoluo/connect-two-webrtc-peers-with-manual-signaling-1o2h</guid>
      <description>&lt;p&gt;Many WebRTC examples reduce connection setup to “exchange the SDP.” That is not enough when a connection fails.&lt;/p&gt;

&lt;p&gt;A working connection involves at least three kinds of information:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Offer:&lt;/strong&gt; what the initiator can send and receive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answer:&lt;/strong&gt; which capabilities the receiver accepts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ICE candidates:&lt;/strong&gt; network paths the two peers might use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open the experiment
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://uuup.net/en/tools/dev/webrtc-tester/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=problem_tutorials&amp;amp;utm_content=001-webrtc-manual-connection" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi5wvvunf1loyjaipsz5l.png" alt="WebRTC manual signaling tester" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://uuup.net/en/tools/dev/webrtc-tester/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=problem_tutorials&amp;amp;utm_content=001-webrtc-manual-connection" rel="noopener noreferrer"&gt;Open the WebRTC manual signaling tester&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Open it on two devices over HTTPS. Allow a camera or microphone, select &lt;strong&gt;Manual signaling (P2P)&lt;/strong&gt; on both pages, and start capture.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Initiator: creating the Offer
&lt;/h2&gt;

&lt;p&gt;The initiator creates an &lt;code&gt;RTCPeerConnection&lt;/code&gt; and adds its local media tracks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;peer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RTCPeerConnection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;iceServers&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTracks&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addTrack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createOffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLocalDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;createOffer()&lt;/code&gt; produces the media negotiation description. &lt;code&gt;setLocalDescription()&lt;/code&gt; starts local ICE gathering.&lt;/p&gt;

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

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

&lt;ol&gt;
&lt;li&gt;Set the local description.&lt;/li&gt;
&lt;li&gt;Wait for ICE gathering to complete, with a 10-second limit.&lt;/li&gt;
&lt;li&gt;Refuse to create a code unless the SDP contains at least one &lt;code&gt;a=candidate:&lt;/code&gt; line.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The invitation is not raw SDP. The tester wraps it in a small envelope:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sessionId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"random ID for this exchange"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"offer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"offer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sdp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"createdAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JSON is compressed with Deflate and encoded as Base64 to make copying easier.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Receiver: creating the Answer
&lt;/h2&gt;

&lt;p&gt;The receiver must apply the remote Offer before creating an Answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setRemoteDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;remoteOffer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createAnswer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLocalDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;setRemoteDescription()&lt;/code&gt; tells the browser what the initiator proposed. &lt;code&gt;createAnswer()&lt;/code&gt; then describes what the receiver can accept.&lt;/p&gt;

&lt;p&gt;The receiver also waits for ICE gathering, packages the Answer and candidates, and returns an answer code.&lt;/p&gt;

&lt;p&gt;The initiator finishes signaling with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setRemoteDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;remoteAnswer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its &lt;code&gt;signalingState&lt;/code&gt; should move from &lt;code&gt;have-local-offer&lt;/code&gt; back to &lt;code&gt;stable&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The tester also checks that the invitation and answer have the same &lt;code&gt;sessionId&lt;/code&gt;. This prevents an Answer from an older attempt from being applied to the current connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the three copy-and-paste steps mean
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://uuup.net/en/tools/dev/webrtc-tester/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=problem_tutorials&amp;amp;utm_content=001-webrtc-manual-connection" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4bontihv4op2x9iv70yf.png" alt="Three-step WebRTC manual signaling flow" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Device A creates an invitation:&lt;/strong&gt; Offer plus A's ICE candidates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Device B creates an answer code:&lt;/strong&gt; apply the Offer, then create an Answer plus B's candidates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Device A applies the answer:&lt;/strong&gt; apply the Answer and begin ICE connectivity checks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;What it tells you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;signalingState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whether Offer and Answer were applied in a valid order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iceGatheringState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whether local candidate gathering is complete&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iceConnectionState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whether ICE found a working candidate pair&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;connectionState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The overall &lt;code&gt;RTCPeerConnection&lt;/code&gt; state&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The tester also calls &lt;code&gt;getStats()&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What STUN can and cannot do
&lt;/h2&gt;

&lt;p&gt;STUN helps a browser discover its public mapping and commonly produces a server-reflexive (&lt;code&gt;srflx&lt;/code&gt;) candidate. It does not relay media.&lt;/p&gt;

&lt;p&gt;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 (&lt;code&gt;relay&lt;/code&gt;) candidate and forwards the traffic.&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Having an Offer and Answer does not guarantee network connectivity.&lt;/li&gt;
&lt;li&gt;Having ICE candidates does not guarantee that any pair will work.&lt;/li&gt;
&lt;li&gt;If ICE remains in &lt;code&gt;checking&lt;/code&gt; and then becomes &lt;code&gt;failed&lt;/code&gt;, investigate TURN before rewriting the SDP exchange.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debug in this order
&lt;/h2&gt;

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

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
