<?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: alakkadshaw</title>
    <description>The latest articles on DEV Community by alakkadshaw (@alakkadshaw).</description>
    <link>https://dev.to/alakkadshaw</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%2F815127%2F9a970e94-cd40-4ea2-9d52-ee024e53b717.png</url>
      <title>DEV Community: alakkadshaw</title>
      <link>https://dev.to/alakkadshaw</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alakkadshaw"/>
    <language>en</language>
    <item>
      <title>OpenAI Realtime API over WebRTC: How It Works + When You Need TURN</title>
      <dc:creator>alakkadshaw</dc:creator>
      <pubDate>Mon, 20 Jul 2026 18:37:32 +0000</pubDate>
      <link>https://dev.to/alakkadshaw/openai-realtime-api-over-webrtc-how-it-works-when-you-need-turn-20da</link>
      <guid>https://dev.to/alakkadshaw/openai-realtime-api-over-webrtc-how-it-works-when-you-need-turn-20da</guid>
      <description>&lt;p&gt;Do you need a TURN server to use the OpenAI Realtime API over WebRTC? It is the first question a WebRTC-experienced developer asks, and the answer is stranger than yes or no: on the direct browser-to-OpenAI path you do not — and you could not add your own TURN even if you wanted to.&lt;/p&gt;

&lt;p&gt;But the moment you build the architecture most production voice agents actually ship, TURN comes back onto the critical path. &lt;/p&gt;

&lt;p&gt;We map the real connection topologies, walk the WebRTC handshake with a complete working example we executed against the live API, and give you a decision table for exactly when a TURN server is mandatory versus irrelevant.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2Fv1q1cio57lxz1gndw2dm.png" class="article-body-image-wrapper"&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%2Fv1q1cio57lxz1gndw2dm.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The OpenAI Realtime API runs over WebRTC, WebSocket, or SIP (OpenAI docs, 2026-07-17). On the direct browser-to-OpenAI WebRTC path you do not need your TURN server: OpenAI uses public endpoints, host candidates only, and a TCP/443 fallback, so you can't add TURN anyway. The moment you own a WebRTC leg (browser to your server, or a Python &lt;code&gt;aiortc&lt;/code&gt; agent), TURN over TLS on 443 is &lt;strong&gt;mandatory&lt;/strong&gt; on restrictive networks.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Does the OpenAI Realtime API need a TURN server?
&lt;/h2&gt;

&lt;p&gt;No — not on the direct path, and yes — the instant you add your own server. &lt;/p&gt;

&lt;p&gt;If your browser talks straight to OpenAI, OpenAI owns the WebRTC endpoint. It publishes public, reachable addresses and handles restrictive networks with its own TCP/443 fallback. There is no TURN server for you to configure.&lt;/p&gt;

&lt;p&gt;If you put your own server in the middle — to hold your API key, add tools and guardrails, record calls, or swap providers — you now own a browser-to-your-server WebRTC connection. That leg needs your STUN and TURN, exactly like any other WebRTC app.&lt;/p&gt;

&lt;p&gt;Most real production voice agents are the second case. So the honest answer is "usually yes — but probably not for the reason you'd expect, and not on the leg you'd expect."&lt;/p&gt;

&lt;h2&gt;
  
  
  What the OpenAI Realtime API is in 2026
&lt;/h2&gt;

&lt;p&gt;The Realtime API is OpenAI's low-latency, speech-to-speech interface for building a &lt;a href="https://developers.openai.com/api/docs/guides/realtime" rel="noopener noreferrer"&gt;realtime voice AI&lt;/a&gt; agent. It went generally available on &lt;strong&gt;2025-08-28&lt;/strong&gt; alongside the first production model, &lt;code&gt;gpt-realtime&lt;/code&gt; (OpenAI, accessed 2026-07-17).&lt;/p&gt;

&lt;p&gt;As of &lt;strong&gt;2026-07-17&lt;/strong&gt;, the catalog lists &lt;code&gt;gpt-realtime-2&lt;/code&gt; as the default realtime model, with &lt;code&gt;gpt-realtime-2.1&lt;/code&gt; and a cheaper &lt;code&gt;gpt-realtime-2.1-mini&lt;/code&gt; as the current point releases, plus specialized &lt;code&gt;gpt-realtime-translate&lt;/code&gt; and &lt;code&gt;gpt-realtime-whisper&lt;/code&gt; models (developers.openai.com, accessed 2026-07-17). OpenAI ships these fast — four point releases in about eleven months — so pin a specific model and date it rather than trusting "the latest."&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2F6o0ep1xqw3mxccvbxdwg.png" class="article-body-image-wrapper"&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%2F6o0ep1xqw3mxccvbxdwg.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The API is reachable over three transports, and OpenAI gives explicit guidance on each. This choice decides which leg of your system owns NAT traversal, so read the table with that lens.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Transport&lt;/th&gt;
&lt;th&gt;OpenAI's stated use (verbatim)&lt;/th&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;WebRTC&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"Use for browser and mobile clients that capture or play audio directly."&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /v1/realtime/calls&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;WebSocket&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"Use when your server already receives raw audio from a media pipeline, call system, or worker."&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wss://api.openai.com/v1/realtime&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SIP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"Use for telephony voice agents."&lt;/td&gt;
&lt;td&gt;SIP into &lt;code&gt;/v1/realtime&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Table: OpenAI Realtime API transports and guidance, quoted from the official Realtime guide (developers.openai.com, accessed 2026-07-17).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The load-bearing takeaway: WebRTC is the client-edge transport, WebSocket is the server-side transport. Where your audio originates tells you which transport to use — and whether a browser is a WebRTC peer at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the browser-to-OpenAI WebRTC connection actually works
&lt;/h2&gt;

&lt;p&gt;The direct WebRTC flow skips the signalling server you would normally build. There is no WebSocket handshake to negotiate the call; OpenAI uses plain HTTP for the SDP exchange (OpenAI WebRTC guide, accessed 2026-07-17).&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2Fxt90ptabw4p42lxp6181.png" class="article-body-image-wrapper"&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%2Fxt90ptabw4p42lxp6181.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It runs in four moves, and the code below is the complete flow. We executed it end-to-end against the live API on 2026-07-17; the field names, status codes, and connection states that follow were observed.&lt;/p&gt;

&lt;p&gt;First, your backend mints a short-lived client secret so your real API key never touches the browser.&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="c1"&gt;// server.js (Node 18+) — your standard API key stays server-side.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.openai.com/v1/realtime/client_secrets&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;realtime&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-realtime-2.1-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// pin a model and date it&lt;/span&gt;
      &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;marin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&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;token&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// → { value: "ek_…", expires_at, session }&lt;/span&gt;
&lt;span class="c1"&gt;// hand token.value to the browser; it expires quickly by design&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, the browser creates an &lt;code&gt;RTCPeerConnection&lt;/code&gt;, attaches the mic, opens the events channel, and &lt;strong&gt;POSTs its raw SDP offer&lt;/strong&gt; to OpenAI — which returns the SDP answer in the HTTP response body with a &lt;code&gt;201 Created&lt;/code&gt;.&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="c1"&gt;// browser — fetch the ephemeral key from YOUR backend, never OpenAI directly&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EPHEMERAL_KEY&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})).&lt;/span&gt;&lt;span class="nf"&gt;json&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;pc&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="c1"&gt;// note: no iceServers passed — this is the whole point&lt;/span&gt;

&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ontrack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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;audioEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcObject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;streams&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;        &lt;span class="c1"&gt;// model audio out&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;pc&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;mic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAudioTracks&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;mic&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                        &lt;span class="c1"&gt;// your mic in&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDataChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;oai-events&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                &lt;span class="c1"&gt;// JSON events channel&lt;/span&gt;
&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// session.created, response.*, input_audio_buffer.*&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;response.output_audio_transcript.done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;);&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;pc&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pc&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.openai.com/v1/realtime/calls&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localDescription&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sdp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;EPHEMERAL_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/sdp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&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;pc&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="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;answer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sdp&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;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// 201 + answer SDP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Third, session updates, tool calls, and transcripts flow as JSON over the data channel named &lt;code&gt;oai-events&lt;/code&gt;, using the same schema as the WebSocket API. Fourth, audio is just a normal media track in each direction (OpenAI WebRTC guide, accessed 2026-07-17).&lt;/p&gt;

&lt;p&gt;Here is what our live run observed, in order: the secret minted as &lt;code&gt;{ value, expires_at, session }&lt;/code&gt;; the SDP exchange returned &lt;strong&gt;201 Created&lt;/strong&gt;; ICE went &lt;code&gt;checking → connected&lt;/code&gt; with &lt;strong&gt;no ICE servers configured&lt;/strong&gt;; &lt;code&gt;oai-events&lt;/code&gt; opened; and the model answered our first &lt;code&gt;response.create&lt;/code&gt; out loud. OpenAI's server-side voice activity detection then took further turns off the incoming audio stream — the full round trip, working.&lt;/p&gt;

&lt;p&gt;Notice what is missing: you never pass &lt;code&gt;iceServers&lt;/code&gt; to that &lt;code&gt;RTCPeerConnection&lt;/code&gt;. That omission is deliberate, and it is the key to the entire TURN question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture question: where do STUN and TURN come in?
&lt;/h2&gt;

&lt;p&gt;On the direct browser-to-OpenAI path, STUN and TURN do not come in at all — from your side. OpenAI terminates WebRTC server-side at publicly reachable endpoints and returns &lt;strong&gt;host candidates only, with no STUN or TURN server&lt;/strong&gt; (&lt;a href="https://webrtchacks.com/how-openai-does-webrtc-in-the-new-gpt-realtime/" rel="noopener noreferrer"&gt;webrtcHacks teardown of the GA &lt;code&gt;gpt-realtime&lt;/code&gt; stack&lt;/a&gt;, dated 2025-09-23).&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2F162kk3r5jykecoyvid5k.png" class="article-body-image-wrapper"&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%2F162kk3r5jykecoyvid5k.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The teardown found OpenAI advertising multiple public Azure datacenter endpoints and connecting clients directly to them over &lt;strong&gt;UDP on port 3478 and TCP on port 443&lt;/strong&gt; — with 443/TCP added at GA specifically to pass firewalls that block UDP and non-web ports (webrtcHacks, 2025-09-23).&lt;/p&gt;

&lt;p&gt;That design has a clean consequence. Because OpenAI's endpoint is public and ships its own TCP/443 fallback, the browser-to-OpenAI hop traverses most NATs and many corporate firewalls &lt;strong&gt;without any TURN server on your side&lt;/strong&gt;. Our executed run is the proof in miniature: ICE reached &lt;code&gt;connected&lt;/code&gt; with no ICE servers configured at all.&lt;/p&gt;

&lt;p&gt;And you could not add one if you wanted to. OpenAI controls the answer SDP, so there is no place to inject your relay. If WebRTC muscle memory has you reaching for an &lt;code&gt;iceServers&lt;/code&gt; block here, there is nothing for it to do — on this topology a TURN server is just not necessary&lt;/p&gt;

&lt;p&gt;This is why direct-path failures reported in OpenAI's community forums read as transient service issues, not NAT problems — the direct path rarely fails on NAT because OpenAI engineered the firewall escape hatch into its own endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you DO need TURN: the moment you own a WebRTC leg
&lt;/h2&gt;

&lt;p&gt;Here is the turn. Most production voice agents do &lt;strong&gt;not&lt;/strong&gt; send browser audio straight to OpenAI. They insert a server in the middle — and that server changes everything about connectivity.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2F88qob1tcvwapdc5nnz8g.png" class="article-body-image-wrapper"&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%2F88qob1tcvwapdc5nnz8g.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Why add a server? To keep your API key off the client, add tools and guardrails, run server-side voice activity detection and barge-in, record or transcribe, bridge telephony, or swap the model provider without shipping a new client. All sensible reasons — and all of them create a second WebRTC connection that you own.&lt;/p&gt;

&lt;p&gt;On the &lt;strong&gt;browser-to-your-server&lt;/strong&gt; leg, &lt;em&gt;you&lt;/em&gt; are the WebRTC endpoint. That means you own NAT traversal. A public-IP media server handles most users through &lt;a href="https://medium.com/@jamesbordane57/what-is-a-turn-server-045f186f88a3" rel="noopener noreferrer"&gt;host and server-reflexive candidates&lt;/a&gt; — but users on &lt;strong&gt;symmetric NAT, UDP-blocked corporate, hospital, or bank networks, or restrictive Wi-Fi cannot connect without a TURN relay&lt;/strong&gt;, ideally TURN over TLS on port 443 so it looks like ordinary HTTPS.&lt;/p&gt;

&lt;p&gt;This is not my claim alone. Python's &lt;a href="https://github.com/aiortc/aiortc" rel="noopener noreferrer"&gt;aiortc&lt;/a&gt; uses a standard &lt;code&gt;RTCConfiguration&lt;/code&gt; with &lt;code&gt;iceServers&lt;/code&gt;, and the same NAT rules apply on its browser-facing leg.&lt;/p&gt;

&lt;p&gt;So the decision is not "does OpenAI Realtime need TURN." It is "does &lt;em&gt;my&lt;/em&gt; architecture put a WebRTC leg under &lt;em&gt;my&lt;/em&gt; control." Here is that decision as a table.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topology&lt;/th&gt;
&lt;th&gt;Who owns the client-edge WebRTC leg&lt;/th&gt;
&lt;th&gt;TURN needed?&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A. Direct browser → OpenAI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;OpenAI (public endpoint, host-only)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt; — and you can't add it&lt;/td&gt;
&lt;td&gt;OpenAI's own TCP/443 handles restrictive networks. Simplest path.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;B. Browser → your media server → OpenAI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;You&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Yes&lt;/strong&gt; — STUN + TURN, ideally TURNS/443&lt;/td&gt;
&lt;td&gt;The dominant production pattern. Enterprise networks fail without TURN.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;C. Python/&lt;code&gt;aiortc&lt;/code&gt; agent ↔ browsers&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;You&lt;/strong&gt; (the &lt;code&gt;aiortc&lt;/code&gt; endpoint)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Yes&lt;/strong&gt;, on the browser-facing leg&lt;/td&gt;
&lt;td&gt;Server-side WebRTC in Python; same NAT rules apply.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;D. Telephony / SIP → OpenAI SIP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Your SBC/gateway (SIP, not WebRTC)&lt;/td&gt;
&lt;td&gt;N/A for SIP&lt;/td&gt;
&lt;td&gt;TURN reappears only if a WebRTC softphone leg exists.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;E. Server already has the audio → OpenAI WebSocket&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Nobody (no browser leg)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;WebSocket, no ICE at all.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Python and server-side path
&lt;/h2&gt;

&lt;p&gt;Python builders hit this split constantly, so it deserves its own section. There are two very different Python paths, and only one of them touches ICE.&lt;/p&gt;

&lt;p&gt;If your server &lt;strong&gt;already has the audio&lt;/strong&gt; — from a telephony system, a media pipeline, or a worker — use the WebSocket transport with the &lt;code&gt;openai&lt;/code&gt; Python SDK. There is no browser peer, no ICE, and no TURN.&lt;/p&gt;

&lt;p&gt;If your Python service must &lt;strong&gt;be&lt;/strong&gt; a WebRTC peer — for example a headless agent that browsers connect to directly — you use &lt;code&gt;aiortc&lt;/code&gt;, "WebRTC and ORTC implementation for Python using asyncio" (&lt;a href="https://github.com/aiortc/aiortc" rel="noopener noreferrer"&gt;aiortc&lt;/a&gt;, accessed 2026-07-17). Now you own the browser-facing leg, and you are back in topology C: STUN and TURN required.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2F5qx9bi01gx7cdd7eh8po.png" class="article-body-image-wrapper"&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%2F5qx9bi01gx7cdd7eh8po.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Configuring ICE in &lt;code&gt;aiortc&lt;/code&gt; is a standard &lt;code&gt;RTCConfiguration&lt;/code&gt;. Point it at your relay, preferring TURNS on 443 for locked-down networks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiortc&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RTCConfiguration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RTCIceServer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RTCPeerConnection&lt;/span&gt;

&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RTCConfiguration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iceServers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;RTCIceServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stun:openrelay.metered.ca:80&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;RTCIceServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;turns:openrelay.metered.ca:443?transport=tcp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;from your TURN credential API&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;short-lived secret&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;pc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RTCPeerConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# your browser-facing peer now has a relay
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is exactly Metered Python SDK fits. &lt;a href="https://pypi.org/project/metered-realtime/" rel="noopener noreferrer"&gt;&lt;code&gt;metered-realtime&lt;/code&gt;&lt;/a&gt; (PyPI v1.0.0, async, built on &lt;code&gt;aiortc&lt;/code&gt;) is the SDK for building that browser-facing WebRTC leg in Python, and it &lt;strong&gt;auto-injects Open Relay TURN&lt;/strong&gt; so a Python agent that peers with browsers gets NAT traversal without you standing up coturn.&lt;/p&gt;

&lt;p&gt;To be precise about what it is: &lt;code&gt;metered-realtime&lt;/code&gt; is the transport layer &lt;em&gt;under&lt;/em&gt; your agent, not an OpenAI Realtime client. Your agent still talks to OpenAI over WebSocket or WebRTC; &lt;code&gt;metered-realtime&lt;/code&gt; handles the browser-facing WebRTC peer and its relay.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your voice agent fails on office and hospital Wi-Fi
&lt;/h2&gt;

&lt;p&gt;This is the failure that many devs face, and it maps exactly onto the topology table. When a &lt;a href="https://developers.openai.com/api/docs/guides/realtime" rel="noopener noreferrer"&gt;WebRTC AI&lt;/a&gt; agent "works on my machine" but dies on a customer's corporate network, the broken leg is almost always the one you own.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2Fg5y7fjps1s01ixduluzc.png" class="article-body-image-wrapper"&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%2Fg5y7fjps1s01ixduluzc.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Corporate, hospital, and bank networks block outbound UDP and non-standard ports, and many run deep packet inspection that drops traffic on 443 that is not genuine TLS. Symmetric NAT breaks the direct peer path on top of that. Your host and server-reflexive candidates all fail, and the call never connects.&lt;/p&gt;

&lt;p&gt;TURN over TLS on port 443 is the escape hatch. It performs a real TLS handshake and looks identical to an HTTPS request, so it survives both the firewall and the DPI. For a deeper treatment of why 443 and TURNS specifically are what get through. you can test the TURN over TLS in TURN server testing tools like: &lt;a href="https://www.metered.ca/turn-server-testing" rel="noopener noreferrer"&gt;TURN server testing&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Latency matters here too, because this is voice. A relay three regions away adds audible delay, so a production TURN service with relays near your users — not a single box — is what keeps relayed calls sounding real. This is the same relay reality behind every &lt;a href="https://dev.to/alakkadshaw/turn-for-ai-voice-agents-why-agent-traffic-is-almost-100-relay-3meo"&gt;TURN server for AI agents&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;agent audio is real-time media, and real-time media behind a corporate firewall needs a good relay. And when a relayed call still drops mid-session — networks change, Wi-Fi roams — WebRTC reconnection handling is what gets the user back without a page refresh.&lt;/p&gt;

&lt;p&gt;The fix has two speeds. &lt;a href="https://www.metered.ca/tools/openrelay/" rel="noopener noreferrer"&gt;Open Relay&lt;/a&gt; gives you 20 GB/month of free TURN with ports 80, 443, and TURNS out of the box&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;METERED TURN — for the leg you own (facts dated 2026-07-17)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Metered TURN product:&lt;/strong&gt; 500 MB free trial, then paid.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Production tiers&lt;/strong&gt; (&lt;a href="https://metered.ca/stun-turn" rel="noopener noreferrer"&gt;https://metered.ca/stun-turn&lt;/a&gt;, verified 2026-07-03)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Growth &lt;strong&gt;$99 / 150 GB&lt;/strong&gt;, Business &lt;strong&gt;$199 / 500 GB&lt;/strong&gt;, Enterprise &lt;strong&gt;$499 / 2 TB&lt;/strong&gt;, custom above. Usage is metered as &lt;strong&gt;ingress + egress&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;31+ regions, 100+ edge PoPs&lt;/strong&gt; for low-latency relayed voice.&lt;/li&gt;
&lt;li&gt;Ports &lt;strong&gt;80 / 443 / TURNS&lt;/strong&gt;, dynamic per-session credentials, per-credential analytics, 24/7 human support.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://metered.ca/stun-turn" rel="noopener noreferrer"&gt;Metered's managed TURN service&lt;/a&gt; is the same relay function across 31+ regions with fixed, allowlistable IPs and region pinning — the connectivity most enterprise voice deployments end up needing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What OpenAI Realtime costs
&lt;/h2&gt;

&lt;p&gt;Cost is the other thing that surprises builders, so here are the current list prices. These are OpenAI's published figures per 1M tokens unless noted (developers.openai.com pricing, accessed 2026-07-17).&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2F5bii5m2uvxzo564dbblh.png" class="article-body-image-wrapper"&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%2F5bii5m2uvxzo564dbblh.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Audio in&lt;/th&gt;
&lt;th&gt;Audio out&lt;/th&gt;
&lt;th&gt;Text in / out&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gpt-realtime-2.1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$32.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$64.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$4.00 / $24.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gpt-realtime-2.1-mini&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$10.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$20.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.60 / $2.40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gpt-realtime-translate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.034 / minute&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gpt-realtime-whisper&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.017 / minute&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For per-minute intuition, user audio runs roughly 600 tokens per minute and assistant audio roughly 1,200 tokens per minute. Independent measurements suggest a typical agent costs around &lt;strong&gt;$0.18–$0.46 per minute uncached&lt;/strong&gt;, dropping to roughly &lt;strong&gt;$0.04–$0.10 per minute&lt;/strong&gt; with prompt caching, trimmed tool outputs, and server-side VAD (third-party 2026 measured-session write-ups, accessed 2026-07-17).&lt;/p&gt;

&lt;p&gt;Treat those per-minute figures as independent estimates, not OpenAI's own numbers — methodology varies. The list prices above are the facts; the per-minute ranges are directional.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together: a reference architecture
&lt;/h2&gt;

&lt;p&gt;Stack the pieces and the production shape is clear. A browser captures audio and connects over a WebRTC leg to your backend; your backend runs VAD, tools, and guardrails, then talks to OpenAI; and a TURN relay sits on the browser-facing leg for the users who need it.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2Ft4hovy13gz2q4c2a8bg6.png" class="article-body-image-wrapper"&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%2Ft4hovy13gz2q4c2a8bg6.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You own two things in that picture that OpenAI does not give you: the browser-facing WebRTC leg (which needs TURN) and the signalling for it. If you would rather not wire the backend leg yourself, our free, open-source SDK &lt;strong&gt;LLMRTC&lt;/strong&gt; (&lt;code&gt;@llmrtc/llmrtc-core&lt;/code&gt;, &lt;code&gt;-backend&lt;/code&gt;, &lt;code&gt;-web-client&lt;/code&gt;; Apache 2.0) is a batteries-included version of this backend — browser ⇄ WebRTC ⇄ Node backend ⇄ providers.&lt;/p&gt;

&lt;p&gt;LLMRTC is provider-agnostic and lists OpenAI among its supported providers — its &lt;code&gt;OpenAILLMProvider&lt;/code&gt;, &lt;code&gt;OpenAIWhisperProvider&lt;/code&gt;, and &lt;code&gt;OpenAITTSProvider&lt;/code&gt; are swappable by config (llmrtc.org, accessed 2026-07-17) — and its own docs recommend Open Relay TURN for production. Built by our team, it is the "don't hand-roll the media backend" option for a &lt;a href="https://dev.to/alakkadshaw/build-an-ai-voice-agent-in-typescript-cloud-or-100-local-one-config-swap-40f1"&gt;build AI voice agent&lt;/a&gt; project.&lt;/p&gt;

&lt;p&gt;One more piece you own: signalling for that browser-to-server leg. If you build it yourself, Metered Realtime is free managed signalling with an MIT-licensed open-source client, so you can &lt;a href="https://www.metered.ca/tools/openrelay/webrtc-signaling-server" rel="noopener noreferrer"&gt;start free&lt;/a&gt; instead of standing up your own WebSocket layer. It is the natural companion to the relay — the two things OpenAI's direct path handles for you, and you handle yourself the moment you own a leg.&lt;/p&gt;

&lt;p&gt;That is the whole architecture in one honest sentence: OpenAI gives you the model and a public endpoint; you give yourself the media leg, its relay, and its signalling — and TURN lives on that leg, not on OpenAI's.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2Fy9wvbf9gnpoyezfsyb8w.png" class="article-body-image-wrapper"&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%2Fy9wvbf9gnpoyezfsyb8w.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does the OpenAI Realtime API need a TURN server?
&lt;/h3&gt;

&lt;p&gt;Not on the direct browser-to-OpenAI path. OpenAI terminates WebRTC at public endpoints with host candidates only and a TCP/443 fallback, so that leg traverses most networks without your TURN — and you cannot add one (webrtcHacks, 2025-09-23). You need TURN the moment you own a WebRTC leg, such as browser to your media server, where users on restrictive networks fail without a relay.&lt;/p&gt;

&lt;h3&gt;
  
  
  WebRTC or WebSocket for the OpenAI Realtime API?
&lt;/h3&gt;

&lt;p&gt;Use WebRTC for browser and mobile clients that capture or play audio directly, and WebSocket when your server already has raw audio from a media pipeline, call system, or worker (OpenAI guidance, accessed 2026-07-17). WebRTC is the client-edge transport and involves ICE; WebSocket is the server-side transport with no ICE and no TURN.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my OpenAI Realtime WebRTC agent fail on a corporate network?
&lt;/h3&gt;

&lt;p&gt;Because the failing leg is one you own, not the OpenAI leg. Corporate, hospital, and bank networks block UDP and non-standard ports and inspect port 443, so your browser-to-your-server WebRTC connection cannot use host or server-reflexive candidates. TURN over TLS on port 443 is the fix — it looks like ordinary HTTPS and survives deep packet inspection.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I connect to the OpenAI Realtime API from Python?
&lt;/h3&gt;

&lt;p&gt;Two ways. If your server already has the audio, use the &lt;code&gt;openai&lt;/code&gt; Python SDK over WebSocket — no ICE, no TURN. If your Python service must be a WebRTC peer that browsers connect to, use &lt;code&gt;aiortc&lt;/code&gt; with an &lt;code&gt;RTCConfiguration&lt;/code&gt; that includes STUN and TURN ICE servers, because you now own NAT traversal on the browser-facing leg.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the OpenAI Realtime API generally available, and which model should I use?
&lt;/h3&gt;

&lt;p&gt;Yes. It reached GA on 2025-08-28 with &lt;code&gt;gpt-realtime&lt;/code&gt; (OpenAI, accessed 2026-07-17). As of 2026-07-17 the catalog lists &lt;code&gt;gpt-realtime-2&lt;/code&gt; as default with &lt;code&gt;gpt-realtime-2.1&lt;/code&gt; and &lt;code&gt;gpt-realtime-2.1-mini&lt;/code&gt; as current point releases. Pin a specific model and date it, because OpenAI ships new realtime models every few months.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much does the OpenAI Realtime API cost per minute?
&lt;/h3&gt;

&lt;p&gt;OpenAI prices &lt;code&gt;gpt-realtime-2.1&lt;/code&gt; at $32 per 1M audio-input tokens and $64 per 1M audio-output tokens, with the mini at $10 and $20 (developers.openai.com, accessed 2026-07-17). Independent 2026 measurements suggest roughly $0.18–$0.46 per minute uncached, falling to about $0.04–$0.10 with caching and trimmed outputs — estimates, not OpenAI figures.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;The OpenAI Realtime API over WebRTC does not need a TURN server on the direct path — OpenAI built the firewall escape hatch into its own public endpoints, and you cannot add your own relay there. That is the part existing guides simply do not cover.&lt;/p&gt;

&lt;p&gt;But production voice agents put a server in the loop, and that creates a WebRTC leg you own. On that leg, users behind symmetric NAT and UDP-blocked enterprise networks fail without TURN over TLS on port 443 — the same connectivity problem every serious WebRTC app eventually meets.&lt;/p&gt;

&lt;p&gt;So build the direct path when you can, and the moment you own a media leg, put a real relay under it: &lt;a href="https://www.metered.ca/tools/openrelay/" rel="noopener noreferrer"&gt;free on Open Relay&lt;/a&gt;, or move to &lt;a href="https://www.metered.ca/stun-turn" rel="noopener noreferrer"&gt;Metered's managed TURN service&lt;/a&gt; when you need 31+ regions, fixed IPs, and per-session credentials for relayed voice that actually connects.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About the author:&lt;/strong&gt; This guide was written by James Bordane an Open Source enthusiast&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>webrtc</category>
    </item>
    <item>
      <title>TURN for AI Voice Agents: When Your Agent Needs a Relay — and When It Doesn't</title>
      <dc:creator>alakkadshaw</dc:creator>
      <pubDate>Thu, 16 Jul 2026 15:40:14 +0000</pubDate>
      <link>https://dev.to/alakkadshaw/turn-for-ai-voice-agents-why-agent-traffic-is-almost-100-relay-3meo</link>
      <guid>https://dev.to/alakkadshaw/turn-for-ai-voice-agents-why-agent-traffic-is-almost-100-relay-3meo</guid>
      <description>&lt;p&gt;&lt;strong&gt;Does an AI voice agent need a TURN server?&lt;/strong&gt; Usually yes — and the precise reason matters more than the slogan.&lt;/p&gt;

&lt;p&gt;A voice agent connects a user's browser to a model in the cloud over WebRTC. Many users connect directly, but anyone on a corporate network that blocks UDP can only reach the agent through a relay on TCP port 443.&lt;/p&gt;

&lt;p&gt;That block is per-network, not per-session — so for those users, a &lt;strong&gt;TURN server for AI agents&lt;/strong&gt; isn't optional. Their media is 100% relay-dependent.&lt;/p&gt;

&lt;p&gt;That's the honest version of a claim you've probably seen stated as a flat absolute. Let's fix the absolute, then show you exactly when — and how — to wire the relay in.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2Fkxdoobehe87fxd8p2qhb.png" class="article-body-image-wrapper"&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%2Fkxdoobehe87fxd8p2qhb.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; AI voice agent media travels browser-to-cloud over WebRTC, not peer-to-peer, so when the direct path is blocked a relay is the only fallback. Most home users connect directly, but corporate firewalls that block UDP force every session onto a TURN relay over TCP/443, and some platforms (AWS Bedrock AgentCore) mandate TURN outright.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How an AI voice agent actually connects
&lt;/h2&gt;

&lt;p&gt;Start with the shape of the connection, because everything downstream follows from it.&lt;/p&gt;

&lt;p&gt;A voice agent is not a peer-to-peer call. It's a browser talking to a model running on a server in the cloud, over a single WebRTC connection. Your microphone audio flows up; the agent's synthesized voice streams back down.&lt;/p&gt;

&lt;p&gt;That means there is exactly &lt;strong&gt;one remote endpoint&lt;/strong&gt; — the cloud — and exactly &lt;strong&gt;one non-direct option&lt;/strong&gt; if the direct path fails: a relay.&lt;/p&gt;

&lt;p&gt;Compare that to a human-to-human call, where two peers can sometimes find a local network path to each other, or fall back through a relay if not. An agent has no second peer to try. There's no LAN-local shortcut, no alternate route — just the cloud endpoint and whatever path that can reach it.&lt;/p&gt;

&lt;p&gt;So the relay isn't a nice-to-have you bolt on for edge cases. It's the only insurance the architecture leaves you when the direct path is blocked.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2Fbuavvlkgtqao0i4p5ls9.png" class="article-body-image-wrapper"&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%2Fbuavvlkgtqao0i4p5ls9.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How much agent traffic actually needs a relay?
&lt;/h2&gt;

&lt;p&gt;It depends on the network — and that dependence is the whole answer. Relay usage varies widely: some users never touch a relay, others can't connect without one.&lt;/p&gt;

&lt;p&gt;The cloud agent has a &lt;strong&gt;public IP&lt;/strong&gt;, so a user on an open home connection usually reaches it &lt;strong&gt;directly&lt;/strong&gt; — no relay involved at all. Some vendor marketing rounds this up to an absolute, but that skips the users who connect straight through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three cases where a relay is the only path
&lt;/h2&gt;

&lt;p&gt;Here's the claim worth carrying. Not "all agent traffic is 100% relay" — instead, three specific, defensible cases where a voice agent's media is fully relay-dependent, and a fourth reality that ties them together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One: no second peer, no shortcut.&lt;/strong&gt; Because the agent has a single cloud endpoint, a relay is the only non-direct path. When the direct route fails, there is nothing else to try&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two: UDP-blocking is binary per network.&lt;/strong&gt; Most home users on open UDP connect straight to the agent — no TURN needed. But a locked-down corporate or enterprise LAN blocks UDP entirely and allows outbound traffic on only a few ports. There, TURN over TLS on port 443 "is often the only path that gets through, because that port looks like ordinary HTTPS traffic". For that population it isn't 15% — it's effectively 100%, because the block is per-network, not per-session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three: some platforms mandate TURN by architecture.&lt;/strong&gt; AWS Bedrock AgentCore's WebRTC runtime states flatly that "TURN relay is required for media traffic between the client and the agent," and offers Amazon KVS as managed TURN (&lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-webrtc.html" rel="noopener noreferrer"&gt;AWS docs, 2026-03&lt;/a&gt;). On those platforms, TURN is part of the connection path regardless of the user's network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the fourth reality:&lt;/strong&gt; because you can't predict which of your users sits behind a UDP-blocking firewall or a carrier-grade NAT, you provision the relay for all of them. The agent that "works in the demo" is the one that skipped this step and hasn't met a corporate user yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern we see: works on your network, breaks on mobile and in the office
&lt;/h2&gt;

&lt;p&gt;A developer's voice agent works perfectly on their local network. Then they connect a device over a mobile phone, or from inside an office network — and it fails. Audio never arrives, even though the app says "connected."&lt;/p&gt;

&lt;p&gt;Nearly every time, the cause is the same: TURN wasn't configured properly. Once we walk the team through a correct TURN setup, the agent works everywhere — on mobile networks as well as inside offices.&lt;/p&gt;

&lt;p&gt;Two mechanisms sit behind that one symptom. &lt;strong&gt;Mobile networks&lt;/strong&gt; typically run carrier-grade NAT (CGNAT), which behaves like symmetric NAT and makes the address STUN discovers unusable to the far side. &lt;strong&gt;Office and campus Wi-Fi&lt;/strong&gt; blocks UDP outright at the firewall.&lt;/p&gt;

&lt;p&gt;Different mechanism, same result: the direct path dies, and only a relay on TCP/443 survives.&lt;/p&gt;

&lt;p&gt;The reason this is so easy to miss is that your development machine is the one environment where none of it applies. On localhost there's no network to cross. Ship to real users on real networks, and the relay is suddenly load-bearing.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2Fn9ex1ysusos0jdp0c8b0.png" class="article-body-image-wrapper"&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%2Fn9ex1ysusos0jdp0c8b0.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TURN requirement by deployment scenario (2026)
&lt;/h2&gt;

&lt;p&gt;Rather than argue percentages, here's the decision laid out by scenario. Each row is dated and sourced, so you can map your own deployment to a row and know where you stand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TURN requirement by deployment scenario — verified 2026-07-15:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Deployment scenario&lt;/th&gt;
&lt;th&gt;Direct path works?&lt;/th&gt;
&lt;th&gt;TURN relay needed?&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Home / local residential user, UDP open&lt;/td&gt;
&lt;td&gt;Often no&lt;/td&gt;
&lt;td&gt;Frequently&lt;/td&gt;
&lt;td&gt;STUN-assisted direct path to the agent's public IP is sometimes available&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mobile / carrier-grade NAT&lt;/td&gt;
&lt;td&gt;Often no&lt;/td&gt;
&lt;td&gt;Frequently&lt;/td&gt;
&lt;td&gt;CGNAT behaves like symmetric NAT and blocks inbound UDP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Corporate / enterprise, UDP blocked&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes — effectively 100%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Only TCP/443 escapes; TURN over TLS is the sole path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Symmetric NAT (either side)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;STUN-discovered address is unusable to the far side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Bedrock AgentCore (KVS)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Required by platform&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Docs state that TURN relay is required for media traffic between the client and the agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI hosted Realtime&lt;/td&gt;
&lt;td&gt;Yes, handled&lt;/td&gt;
&lt;td&gt;Handled by OpenAI&lt;/td&gt;
&lt;td&gt;Private ICE-TCP relay-transceiver, not classic TURN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted framework ( aiortc / generic)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes — you add it&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No hyperscaler relay network; TURN over port 443 provides firewall traversal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read down the "TURN relay needed?" column and the pattern is obvious. The only rows where you can skip a relay are the open-home case and the hosted platforms that quietly run their own relay for you. Every self-hosted or enterprise-facing deployment lands on "yes."&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2F1bvmrkdu2k70hz4vkafh.png" class="article-body-image-wrapper"&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%2F1bvmrkdu2k70hz4vkafh.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How to wire TURN into your voice agent
&lt;/h2&gt;

&lt;p&gt;The mechanism, then the config. WebRTC gathers connection candidates, and you hand it a list of ICE servers to try: STUN discovers your public address, and TURN relays your media when a direct path is impossible. If those three acronyms are fuzzy, this &lt;a href="https://dev.to/aprogrammer22/stun-vs-turn-vs-ice-the-webrtc-networking-explained-4jpn"&gt;STUN vs TURN vs ICE explainer&lt;/a&gt; is a clean primer.&lt;/p&gt;

&lt;p&gt;For a voice agent, you pass an &lt;code&gt;iceServers&lt;/code&gt; array into your peer connection — the same shape whether you use raw WebRTC or a framework. A minimal config includes a STUN entry and a TURN entry with credentials:&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;iceServers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stun:&amp;lt;your-stun-url&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;turn:&amp;lt;your-turn-url&amp;gt;:443?transport=tcp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// TCP/443 survives UDP-blocking firewalls&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;turns:&amp;lt;your-turn-url&amp;gt;:443&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;// TURN over TLS, looks like HTTPS&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;short-lived-username&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;short-lived-credential&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;pc&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details do the heavy lifting. The &lt;code&gt;transport=tcp&lt;/code&gt; on port 443 is the entry that gets through corporate firewalls, and the &lt;code&gt;turns:&lt;/code&gt; (TURN over TLS) entry makes that traffic look like ordinary HTTPS. Serve both, and the strict-network users from the scenario table can finally connect.&lt;/p&gt;

&lt;p&gt;For debugging, one setting is worth knowing: &lt;code&gt;iceTransportPolicy: "relay"&lt;/code&gt; forces every candidate through TURN. Set it during testing to confirm your relay path works before real firewalls are in the picture — if it connects with &lt;code&gt;relay&lt;/code&gt; forced, your locked-down users will connect too.&lt;/p&gt;

&lt;p&gt;There's a second piece your agent needs, and it's easy to forget in the media excitement: a &lt;strong&gt;signalling channel&lt;/strong&gt; to exchange those SDP offers and ICE candidates in the first place. That's the "and how do the two sides find each other?" question. &lt;a href="https://www.metered.ca/tools/openrelay/webrtc-signaling-server/" rel="noopener noreferrer"&gt;Metered Realtime&lt;/a&gt; provides managed signalling free — 100 concurrent connections and 100,000 messages a month, with an MIT-licensed open-source client — so the same vendor covering your relay can cover the control channel too, without a second integration.&lt;/p&gt;

&lt;p&gt;If you'd rather see this end to end, we built and measured a full TypeScript voice agent — mic to model and back, with the TURN step wired in — in &lt;a href="https://dev.to/alakkadshaw/build-an-ai-voice-agent-in-typescript-cloud-or-100-local-one-config-swap-40f1"&gt;this build-a-voice-agent walkthrough&lt;/a&gt;. It's the build-side companion to this infrastructure piece.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2Fgbvinsoiyj23ei0u2nv7.png" class="article-body-image-wrapper"&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%2Fgbvinsoiyj23ei0u2nv7.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TURN for self-hosted LiveKit Agents
&lt;/h2&gt;

&lt;p&gt;If you're running &lt;strong&gt;LiveKit Agents&lt;/strong&gt; self-hosted, the NAT-and-firewall reality is exactly the same — your media crosses the same hostile networks as any other WebRTC app, and remote users behind UDP-blocking firewalls need a relay to reach your deployment.&lt;/p&gt;

&lt;p&gt;LiveKit's server can run an embedded TURN service, but many teams point a self-hosted deployment at an &lt;strong&gt;external, multi-region TURN service&lt;/strong&gt; for production reach and redundancy. You do this by supplying external TURN URLs and short-lived credentials in the server's ICE/TURN configuration, so every client LiveKit provisions receives relay candidates on TCP/443 alongside the usual STUN and UDP options.&lt;/p&gt;

&lt;p&gt;The wiring is the same principle as the &lt;code&gt;iceServers&lt;/code&gt; block above — a STUN entry plus a &lt;code&gt;turn:&lt;/code&gt;/&lt;code&gt;turns:&lt;/code&gt; entry on port 443 — just applied at the LiveKit-server layer instead of per peer connection. Point it at a relay with broad regional coverage and 24/7 support, and your self-hosted LiveKit Agents deployment inherits the enterprise-firewall traversal it needs.&lt;/p&gt;

&lt;p&gt;Both &lt;a href="https://www.metered.ca/stun-turn" rel="noopener noreferrer"&gt;Metered's TURN service&lt;/a&gt; and the free &lt;a href="https://www.metered.ca/tools/openrelay/" rel="noopener noreferrer"&gt;Open Relay&lt;/a&gt; network slot in here as that external TURN target.&lt;/p&gt;

&lt;h2&gt;
  
  
  TURN is table stakes — the honest bottom line
&lt;/h2&gt;

&lt;p&gt;Here's the position we'll stake our name on, after a decade of running relays for other people's WebRTC.&lt;/p&gt;

&lt;p&gt;TURN is table stakes when you're working with WebRTC. Many peer-to-peer connections simply don't hold up in the real world — especially when it matters most — because of NAT and firewall rules.&lt;/p&gt;

&lt;p&gt;Mobile networks sit behind CGNAT. The Wi-Fi inside hospitals, schools, and offices blocks the direct path. Those are precisely the places your agent will be used.&lt;/p&gt;

&lt;p&gt;So the pragmatic move isn't to debug the failing 15% after launch. It's to provision the relay from day one, offer it on TCP/443 with TLS, and mint short-lived credentials — then stop thinking about NAT and ship. The relay is cheap insurance against the exact users you most want to impress.&lt;/p&gt;

&lt;p&gt;"100% relay" was never the right way to say it. "You cannot ship a production voice agent without a relay for the users whose direct path is blocked" — that's the truth, and it's enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How much AI voice-agent traffic actually needs a TURN relay?
&lt;/h3&gt;

&lt;p&gt;It varies widely by network, so no single percentage fits. Users on open home connections often reach the agent's public-IP server directly, with no relay at all.&lt;/p&gt;

&lt;p&gt;On UDP-blocking corporate networks, every session needs a TURN relay over TCP/443, because that's the only path out. Some platforms also mandate TURN regardless of the user's network.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the OpenAI Realtime API need a TURN server?
&lt;/h3&gt;

&lt;p&gt;OpenAI's hosted Realtime service handles connectivity itself — it engineered a private relay-transceiver over ICE-TCP and skips classic TURN (&lt;a href="https://www.infoq.com/news/2026/05/openai-voice-ai-scale/" rel="noopener noreferrer"&gt;InfoQ, 2026-05-20&lt;/a&gt;). But if you self-host the agent instead of using OpenAI's endpoint, you don't have their global relay network. You add a TURN server on 443 to get the same firewall traversal for your own users.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my voice agent work locally but fail for real users?
&lt;/h3&gt;

&lt;p&gt;On localhost there's no network to cross, so WebRTC connects trivially. Real users sit behind NATs and corporate firewalls that block direct UDP — mobile CGNAT and office Wi-Fi are the usual culprits. Without a TURN relay carrying media over TCP/443, the connection has nowhere to go, and audio never reaches the agent despite a "connected" status.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does WebRTC fail on corporate networks?
&lt;/h3&gt;

&lt;p&gt;Corporate firewalls commonly block UDP and allow outbound traffic on only a few ports. WebRTC's default UDP media path can't get out, so the connection fails silently. The fix is a TURN server offering TURN over TLS on port 443, which looks like ordinary HTTPS traffic and passes straight through the firewall&lt;/p&gt;

&lt;h3&gt;
  
  
  Do managed agent platforms require TURN?
&lt;/h3&gt;

&lt;p&gt;Some do, by architecture. AWS Bedrock AgentCore's WebRTC runtime states that TURN relay is required for media between the client and the agent, and offers Amazon KVS as managed TURN (&lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-webrtc.html" rel="noopener noreferrer"&gt;AWS docs, 2026-03&lt;/a&gt;). On those platforms TURN isn't optional — it's part of the connection path regardless of the user's network.&lt;/p&gt;

&lt;h3&gt;
  
  
  coturn or managed TURN for a voice agent?
&lt;/h3&gt;

&lt;p&gt;coturn is free software, but you own TLS certificates, credential rotation, bandwidth, DDoS exposure, and patching across regions. Managed TURN handles all of it with global coverage and an SLA. Self-host if you have the ops capacity and volume; use a managed relay to ship reliably without running relay infrastructure yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting started with Metered TURN for voice agents
&lt;/h2&gt;

&lt;p&gt;If you'd rather not run relays across a dozen regions, &lt;a href="https://www.metered.ca/stun-turn" rel="noopener noreferrer"&gt;Metered's TURN service&lt;/a&gt; is built for exactly the deployment reality above.&lt;/p&gt;

&lt;p&gt;You get 31+ regions and 100+ edge PoPs, TURN on ports 80 and 443 with TURNS/TLS, dynamic short-lived credentials, and 24/7 human support from a team that has operated production TURN, STUN, and signalling for a decade.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About the author:&lt;/strong&gt; This guide was written by James Bordane, a developer, and open source enthusiast and network engineer&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>webrtc</category>
      <category>javascript</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>alakkadshaw</dc:creator>
      <pubDate>Wed, 15 Jul 2026 15:44:09 +0000</pubDate>
      <link>https://dev.to/alakkadshaw/-1lpm</link>
      <guid>https://dev.to/alakkadshaw/-1lpm</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/alakkadshaw/build-an-ai-voice-agent-in-typescript-cloud-or-100-local-one-config-swap-40f1" class="crayons-story__hidden-navigation-link"&gt;Build an AI Voice Agent in TypeScript — Cloud or 100% Local, One Config Swap&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/alakkadshaw" class="crayons-avatar  crayons-avatar--l  "&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%2Fuser%2Fprofile_image%2F815127%2F9a970e94-cd40-4ea2-9d52-ee024e53b717.png" alt="alakkadshaw profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/alakkadshaw" class="crayons-story__secondary fw-medium m:hidden"&gt;
              alakkadshaw
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                alakkadshaw
                
              
              &lt;div id="story-author-preview-content-4144083" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/alakkadshaw" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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%2Fuser%2Fprofile_image%2F815127%2F9a970e94-cd40-4ea2-9d52-ee024e53b717.png" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;alakkadshaw&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/alakkadshaw/build-an-ai-voice-agent-in-typescript-cloud-or-100-local-one-config-swap-40f1" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jul 14&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/alakkadshaw/build-an-ai-voice-agent-in-typescript-cloud-or-100-local-one-config-swap-40f1" id="article-link-4144083"&gt;
          Build an AI Voice Agent in TypeScript — Cloud or 100% Local, One Config Swap
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/typescript"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;typescript&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/tutorial"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;tutorial&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/alakkadshaw/build-an-ai-voice-agent-in-typescript-cloud-or-100-local-one-config-swap-40f1" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;10&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/alakkadshaw/build-an-ai-voice-agent-in-typescript-cloud-or-100-local-one-config-swap-40f1#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              2&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            12 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Build an AI Voice Agent in TypeScript — Cloud or 100% Local, One Config Swap</title>
      <dc:creator>alakkadshaw</dc:creator>
      <pubDate>Tue, 14 Jul 2026 20:15:53 +0000</pubDate>
      <link>https://dev.to/alakkadshaw/build-an-ai-voice-agent-in-typescript-cloud-or-100-local-one-config-swap-40f1</link>
      <guid>https://dev.to/alakkadshaw/build-an-ai-voice-agent-in-typescript-cloud-or-100-local-one-config-swap-40f1</guid>
      <description>&lt;p&gt;You can &lt;strong&gt;build an AI voice agent&lt;/strong&gt; in TypeScript with &lt;a href="https://llmrtc.org" rel="noopener noreferrer"&gt;LLMRTC&lt;/a&gt; — an open-source SDK that streams your microphone over WebRTC through a speech-to-text → LLM → text-to-speech pipeline, then swaps OpenAI for a fully local stack by changing config, not code.&lt;/p&gt;

&lt;p&gt;That last part is the whole point of this tutorial. Every ranking guide for this locks you to one vendor: OpenAI's realtime models, one STT service, one telephony platform.&lt;/p&gt;

&lt;p&gt;Here you'll build the agent once, run it on OpenAI, then run the &lt;em&gt;same&lt;/em&gt; agent 100% offline — no keys, no cloud. And we'll cover the step almost every tutorial skips: what happens when a real user, behind a corporate firewall, tries to connect.&lt;/p&gt;

&lt;p&gt;We ran this whole thing live before publishing. Every number below is measured on our own machine, not a spec sheet.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2Fnbc8grgz0gu8mhoxjh9u.png" class="article-body-image-wrapper"&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%2Fnbc8grgz0gu8mhoxjh9u.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; To build a real-time AI voice agent in TypeScript, use LLMRTC — an open-source SDK that streams audio over WebRTC through an STT→LLM→TTS pipeline. Unlike vendor-locked tutorials, LLMRTC swaps LLM/STT/TTS providers by config, so the same agent runs on OpenAI or fully local (Ollama + Faster-Whisper + Piper). For real users behind NAT/firewalls, add a TURN server (Open Relay is free).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What you're building
&lt;/h2&gt;

&lt;p&gt;You're building a two-way voice conversation in the browser: you speak, an AI agent answers out loud, and you can cut it off mid-sentence.&lt;/p&gt;

&lt;p&gt;The shape is simple. Your browser captures the microphone and sends audio to a Node backend over WebRTC. The backend runs the pipeline — speech-to-text, then an LLM, then text-to-speech — and streams the agent's voice back over the same connection.&lt;/p&gt;

&lt;p&gt;Three stages do the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;STT (speech-to-text)&lt;/strong&gt; turns your spoken audio into text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM&lt;/strong&gt; reads that text and generates a reply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TTS (text-to-speech)&lt;/strong&gt; turns the reply back into audio.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Streaming ties it together. Playback starts before the full reply is generated, so the agent feels responsive instead of walkie-talkie slow.&lt;/p&gt;

&lt;p&gt;The feature that makes it feel human is &lt;strong&gt;barge-in&lt;/strong&gt;. When you start talking over the agent, server-side voice-activity detection (VAD) hears you and cancels the agent's speech instantly — just like interrupting a person. LLMRTC handles VAD and barge-in on the server, so you don't hand-roll it.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2F8u4aqv9azx18vj51jn9y.png" class="article-body-image-wrapper"&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%2F8u4aqv9azx18vj51jn9y.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why LLMRTC
&lt;/h2&gt;

&lt;p&gt;LLMRTC is the right base here for four concrete reasons — and one deliberate trade-off we'll name up front.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's TypeScript-native, end to end.&lt;/strong&gt; The backend and the browser client are both TypeScript. No Python service to stand up beside your Node app, which is where most open-source voice stacks send you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's provider-agnostic by config.&lt;/strong&gt; OpenAI is one option among many for the LLM, STT, and TTS layers. You swap providers by editing a config object — not by rewriting your app. That's the swap we'll demonstrate live in a later section.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's Apache 2.0, with no platform.&lt;/strong&gt; There's no cloud control plane you rent, no per-minute platform fee, no dashboard you're forced through. You run the backend yourself, and it's feature-complete today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's built by an infrastructure team, and it's open.&lt;/strong&gt; LLMRTC is built and maintained by Metered, which has operated production WebRTC infrastructure (TURN, STUN, signalling) for a decade. Full disclosure so you know where it comes from: the SDK is Apache 2.0 on &lt;a href="https://github.com/llmrtc/llmrtc" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, and it's genuinely free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;You need three things before the first line of code: Node.js 20+, FFmpeg, and the three LLMRTC packages.&lt;/p&gt;

&lt;p&gt;Check your Node version first — LLMRTC requires Node 20 or newer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;--version&lt;/span&gt;   &lt;span class="c"&gt;# v20.x or higher&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install &lt;strong&gt;FFmpeg&lt;/strong&gt;. LLMRTC uses it to convert streaming TTS audio, so it's required, not optional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# macOS&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;ffmpeg
&lt;span class="c"&gt;# Debian/Ubuntu&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;ffmpeg
&lt;span class="c"&gt;# Windows&lt;/span&gt;
choco &lt;span class="nb"&gt;install &lt;/span&gt;ffmpeg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now create the project as an ES module and install the SDK. The three packages split cleanly: &lt;code&gt;-backend&lt;/code&gt; runs the pipeline, &lt;code&gt;-web-client&lt;/code&gt; runs in the browser, and &lt;code&gt;-core&lt;/code&gt; holds the shared types.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm pkg &lt;span class="nb"&gt;set type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;module
npm &lt;span class="nb"&gt;install&lt;/span&gt; @llmrtc/llmrtc-backend @llmrtc/llmrtc-web-client @llmrtc/llmrtc-core
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; tsx typescript @types/node
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the cloud stack you'll need one OpenAI API key. Put it in a &lt;code&gt;.env&lt;/code&gt; file — never commit it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env&lt;/span&gt;
&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire setup. No accounts, no CLI login, no media server. Next: the backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backend (about 40 lines)
&lt;/h2&gt;

&lt;p&gt;The whole backend is one file. It configures a provider set, hands it to an &lt;code&gt;LLMRTCServer&lt;/code&gt;, and starts listening.&lt;/p&gt;

&lt;p&gt;Here's the complete &lt;code&gt;server.ts&lt;/code&gt; we ran — including both the cloud and local provider sets, selected by an env var:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dotenv/config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;LLMRTCServer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;OpenAILLMProvider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;OpenAIWhisperProvider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;OpenAITTSProvider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;OllamaLLMProvider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;FasterWhisperProvider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;PiperTTSProvider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ConversationProviders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@llmrtc/llmrtc-backend&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// The entire cloud -&amp;gt; local swap lives in this one block.&lt;/span&gt;
&lt;span class="c1"&gt;// STACK=cloud (default) uses OpenAI; STACK=local runs 100% on your machine.&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STACK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STACK&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cloud&lt;/span&gt;&lt;span class="dl"&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;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ConversationProviders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;STACK&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;local&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OllamaLLMProvider&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;llama3.2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="c1"&gt;// Ollama on :11434&lt;/span&gt;
        &lt;span class="na"&gt;stt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FasterWhisperProvider&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// faster-whisper server on :9000&lt;/span&gt;
        &lt;span class="na"&gt;tts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PiperTTSProvider&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en_US-amy-medium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="c1"&gt;// Piper on :5002&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OpenAILLMProvider&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-5.6-terra&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="na"&gt;stt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OpenAIWhisperProvider&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="na"&gt;tts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OpenAITTSProvider&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alloy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&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;server&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;LLMRTCServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;systemPrompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`You are a helpful voice assistant.
Keep responses concise and conversational.
Respond in 1-2 sentences when possible.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;streamingTTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8787&lt;/span&gt;&lt;span class="p"&gt;,&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;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;STACK&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] voice agent running on ws://localhost:8787`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it top to bottom and it explains itself.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;providers&lt;/code&gt; object is the agent's brain. Each layer — &lt;code&gt;llm&lt;/code&gt;, &lt;code&gt;stt&lt;/code&gt;, &lt;code&gt;tts&lt;/code&gt; — is a provider instance, and swapping a layer means swapping one line. Keep that in mind; it's the payoff later.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;systemPrompt&lt;/code&gt; shapes the agent's personality. We ask for short, conversational replies because long monologues feel wrong in a voice UI.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;streamingTTS: true&lt;/code&gt; is what makes it feel live — audio starts flowing before the reply is fully written. And &lt;code&gt;port: 8787&lt;/code&gt; is where the browser client will connect.&lt;/p&gt;

&lt;p&gt;One call — &lt;code&gt;await server.start()&lt;/code&gt; — and the pipeline is live. That's the backend, done.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2Flpo9zqmtlo3rjvz0dfav.png" class="article-body-image-wrapper"&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%2Flpo9zqmtlo3rjvz0dfav.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The browser client
&lt;/h2&gt;

&lt;p&gt;The browser side captures the mic, plays the agent's voice, and listens to a handful of events. Here are the parts that matter — the full client, with the on-page latency panel, is in the &lt;a href="https://github.com/llmrtc/llmrtc" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Create the client and point it at the backend:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;LLMRTCWebClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://esm.sh/@llmrtc/llmrtc-web-client@1.2.0&lt;/span&gt;&lt;span class="dl"&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;client&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;LLMRTCWebClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;signallingUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ws://localhost:8787&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Starting the conversation is a click handler: start the client, grab the mic, and share it.&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="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;talk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;onclick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &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="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;talk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&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;micStream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shareAudio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;micStream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;talk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Listening — just speak&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From there, the client emits events you subscribe to: &lt;code&gt;transcript&lt;/code&gt; (what you said), &lt;code&gt;llmChunk&lt;/code&gt; (the reply streaming in), &lt;code&gt;ttsStart&lt;/code&gt; and &lt;code&gt;ttsComplete&lt;/code&gt; (agent speaking), and &lt;code&gt;ttsCancelled&lt;/code&gt; (barge-in fired).&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha #1: keep one audio element alive for the whole session
&lt;/h3&gt;

&lt;p&gt;This one cost us real time, so here's the fix straight up. The agent's TTS audio arrives on &lt;strong&gt;one persistent WebRTC track per session&lt;/strong&gt; — not a fresh track per turn.&lt;/p&gt;

&lt;p&gt;A naive &lt;code&gt;ttsCancelled&lt;/code&gt; handler that tears the audio element down — &lt;code&gt;audio.pause(); audio.srcObject = null&lt;/code&gt;, roughly the shape a quick read of the docs suggests — silences &lt;em&gt;every turn after the first barge-in&lt;/em&gt;. The symptom is nasty because it looks like a logic bug, not an audio bug: transcripts keep flowing, the agent keeps "replying," but there's no sound.&lt;/p&gt;

&lt;p&gt;The fix is to keep a single &lt;code&gt;Audio&lt;/code&gt; element alive for the session and never destroy it on cancel. On barge-in, just let the server's cancel stop the stream:&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="c1"&gt;// ONE persistent element for the session: the TTS audio arrives on a single&lt;/span&gt;
&lt;span class="c1"&gt;// WebRTC track. Destroying the element on ttsCancelled (as a naive reading of&lt;/span&gt;
&lt;span class="c1"&gt;// the docs suggests) silences every later turn — the gotcha we hit live.&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;currentAudio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ttsTrack&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[ttsTrack] track event fired&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;currentAudio&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;Audio&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;currentAudio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcObject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;currentAudio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the next &lt;code&gt;ttsStart&lt;/code&gt;, if the element is paused, call &lt;code&gt;play()&lt;/code&gt; again — don't rebuild it. That single decision is the difference between a demo that works once and one that survives a real back-and-forth.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2Fa314fcj61qp0py9q4t3o.png" class="article-body-image-wrapper"&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%2Fa314fcj61qp0py9q4t3o.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it: our measured numbers
&lt;/h2&gt;

&lt;p&gt;Now the fun part — running it and watching real latency. Install, add your key, start the backend, and serve the client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install
cp&lt;/span&gt; .env.example .env   &lt;span class="c"&gt;# put your OPENAI_API_KEY in .env&lt;/span&gt;
npm run cloud          &lt;span class="c"&gt;# backend on ws://localhost:8787&lt;/span&gt;
npm run serve          &lt;span class="c"&gt;# client on http://localhost:3000 (separate terminal)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:3000&lt;/code&gt;, click &lt;strong&gt;Start talking&lt;/strong&gt;, allow the mic, and speak. You'll see your words appear as a transcript, the agent's reply stream in, then hear it out loud. Talk over it mid-reply and it stops — that's barge-in.&lt;/p&gt;

&lt;p&gt;We instrumented the client with &lt;code&gt;performance.now()&lt;/code&gt; between SDK events and ran two multi-turn sessions on the cloud stack. Here's exactly what we got.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measured on our run, 2026-07-14 — MacBook Pro M1 Pro, 16 GB RAM, residential network. OpenAI GPT + Whisper STT + OpenAI TTS, &lt;code&gt;streamingTTS: true&lt;/code&gt;.&lt;/strong&gt; These are our numbers on our hardware, not an LLMRTC or Metered performance guarantee:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Turn&lt;/th&gt;
&lt;th&gt;Speech end → transcript&lt;/th&gt;
&lt;th&gt;→ first LLM token&lt;/th&gt;
&lt;th&gt;→ agent audio&lt;/th&gt;
&lt;th&gt;Barge-in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;S1-T1&lt;/td&gt;
&lt;td&gt;1,267 ms&lt;/td&gt;
&lt;td&gt;2,832 ms&lt;/td&gt;
&lt;td&gt;2,832 ms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S1-T2&lt;/td&gt;
&lt;td&gt;1,841 ms&lt;/td&gt;
&lt;td&gt;2,700 ms&lt;/td&gt;
&lt;td&gt;2,751 ms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S2-T1&lt;/td&gt;
&lt;td&gt;1,414 ms&lt;/td&gt;
&lt;td&gt;3,619 ms&lt;/td&gt;
&lt;td&gt;3,870 ms&lt;/td&gt;
&lt;td&gt;interrupted ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3-T1&lt;/td&gt;
&lt;td&gt;1,214 ms&lt;/td&gt;
&lt;td&gt;2,578 ms&lt;/td&gt;
&lt;td&gt;3,014 ms&lt;/td&gt;
&lt;td&gt;cut ≤1 ms after speech-start ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3-T2&lt;/td&gt;
&lt;td&gt;1,531 ms&lt;/td&gt;
&lt;td&gt;2,295 ms&lt;/td&gt;
&lt;td&gt;2,598 ms&lt;/td&gt;
&lt;td&gt;interrupted again ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;End to end, we measured roughly &lt;strong&gt;2.5–3.9 seconds&lt;/strong&gt; from the end of our speech to the agent's first audio on the default buffered pipeline.&lt;/p&gt;

&lt;p&gt;Here's the insight that matters: &lt;strong&gt;STT dominates.&lt;/strong&gt; Buffered Whisper alone accounts for 1.2–1.8 seconds of that — the single biggest slice. The LLM and TTS are not your bottleneck; transcription is.&lt;/p&gt;

&lt;p&gt;Barge-in was effectively instant. The server cancels TTS the moment its VAD detects speech, and the &lt;code&gt;ttsCancelled&lt;/code&gt; event reached our client within about 1 ms of the &lt;code&gt;speechStart&lt;/code&gt; event — the perceived cut is really just VAD detection time.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2Fsr2h5zudj5qcteb4ipzx.png" class="article-body-image-wrapper"&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%2Fsr2h5zudj5qcteb4ipzx.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Want it faster? LLMRTC's docs describe two paths we did &lt;strong&gt;not&lt;/strong&gt; benchmark here: &lt;code&gt;streamingSTT&lt;/code&gt; with a streaming STT provider (so transcription overlaps your speech instead of waiting for you to finish), and an experimental &lt;code&gt;realtimeSpeech&lt;/code&gt; relay mode. If latency is your priority, start there — see the &lt;a href="https://llmrtc.org" rel="noopener noreferrer"&gt;LLMRTC docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payoff: go 100% local
&lt;/h2&gt;

&lt;p&gt;Here's the moment that no vendor-locked tutorial can show you. &lt;strong&gt;Take the exact same agent and run it fully offline — no OpenAI key, no cloud — by changing configuration only.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Remember the &lt;code&gt;providers&lt;/code&gt; block in &lt;code&gt;server.ts&lt;/code&gt;? The local branch is the &lt;em&gt;only&lt;/em&gt; thing that changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ConversationProviders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OllamaLLMProvider&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;llama3.2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;        &lt;span class="c1"&gt;// Ollama on :11434&lt;/span&gt;
  &lt;span class="na"&gt;stt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FasterWhisperProvider&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;                         &lt;span class="c1"&gt;// faster-whisper on :9000&lt;/span&gt;
  &lt;span class="na"&gt;tts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PiperTTSProvider&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en_US-amy-medium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="c1"&gt;// Piper on :5002&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same &lt;code&gt;LLMRTCServer&lt;/code&gt;, same &lt;code&gt;systemPrompt&lt;/code&gt;, same browser client — different brain. Your application code doesn't move a line. That's provider-agnostic, demonstrated instead of promised.&lt;/p&gt;

&lt;p&gt;To run it, start the three local services and flip the env var:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;ollama &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ollama serve     &lt;span class="c"&gt;# LLM on :11434&lt;/span&gt;
ollama pull llama3.2                    &lt;span class="c"&gt;# ~2 GB&lt;/span&gt;

docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; faster-whisper &lt;span class="nt"&gt;-p&lt;/span&gt; 9001:8000 fedirz/faster-whisper-server:latest-cpu
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; piper &lt;span class="nt"&gt;-p&lt;/span&gt; 5099:5000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;MODEL_DOWNLOAD_LINK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_US/amy/medium/en_US-amy-medium.onnx?download=true"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  artibex/piper-http

npx tsx local-bridge.ts   &lt;span class="c"&gt;# /asr on :9000 + /api/tts on :5002 (separate terminal)&lt;/span&gt;
npm run &lt;span class="nb"&gt;local
&lt;/span&gt;npm run serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the numbers. &lt;strong&gt;Measured on our run, 2026-07-14 — same M1 Pro (16 GB), fully local: Ollama &lt;code&gt;llama3.2&lt;/code&gt; + faster-whisper-small + Piper.&lt;/strong&gt; Again, our machine, not a guarantee:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Turn&lt;/th&gt;
&lt;th&gt;Speech end → transcript&lt;/th&gt;
&lt;th&gt;→ first LLM token&lt;/th&gt;
&lt;th&gt;→ agent audio&lt;/th&gt;
&lt;th&gt;Barge-in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;L-T1&lt;/td&gt;
&lt;td&gt;9,070 ms&lt;/td&gt;
&lt;td&gt;12,207 ms&lt;/td&gt;
&lt;td&gt;12,337 ms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L-T2&lt;/td&gt;
&lt;td&gt;9,318 ms&lt;/td&gt;
&lt;td&gt;9,615 ms&lt;/td&gt;
&lt;td&gt;11,624 ms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L-T3&lt;/td&gt;
&lt;td&gt;9,257 ms&lt;/td&gt;
&lt;td&gt;9,539 ms&lt;/td&gt;
&lt;td&gt;10,810 ms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L-T4&lt;/td&gt;
&lt;td&gt;8,855 ms&lt;/td&gt;
&lt;td&gt;9,218 ms&lt;/td&gt;
&lt;td&gt;10,547 ms&lt;/td&gt;
&lt;td&gt;cut/speech-start gap 7 ms ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L-T6&lt;/td&gt;
&lt;td&gt;10,781 ms&lt;/td&gt;
&lt;td&gt;11,828 ms&lt;/td&gt;
&lt;td&gt;12,576 ms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fully local, we measured about &lt;strong&gt;10.5–12.6 seconds&lt;/strong&gt; end to end on a 16 GB laptop — and once again, STT is the story. The buffered faster-whisper-small model took roughly 9–11 seconds of every turn.&lt;/p&gt;

&lt;p&gt;Barge-in behaved identically to cloud (a 7 ms cut in our run). The Piper TTS container ran x86-emulated on our Apple Silicon and still wasn't the bottleneck.&lt;/p&gt;

&lt;p&gt;So here's the honest cloud-vs-local trade, stated plainly. Cloud gets you 2.5–3.9 s and costs API dollars.&lt;/p&gt;

&lt;p&gt;Local gets you privacy and zero per-request cost, but 10.5–12.6 s on a laptop — the STT model is the lever, and a GPU or a smaller streaming STT model is where you'd claw the seconds back. Pick per use case; the code doesn't care.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2F2jutm3l5gqn90bh7n5qa.png" class="article-body-image-wrapper"&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%2F2jutm3l5gqn90bh7n5qa.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Ship it to real users: NAT and TURN
&lt;/h2&gt;

&lt;p&gt;Your agent works on &lt;code&gt;localhost&lt;/code&gt;. That's exactly why it will break for real users — and this is the section nearly every voice-agent tutorial leaves out.&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;localhost&lt;/code&gt; there's no network to cross, so WebRTC connects trivially. Real users sit behind home routers and corporate firewalls that block direct peer connections. And voice-agent media is almost entirely relay traffic in production — so when the direct path fails, the connection has nowhere to go.&lt;/p&gt;

&lt;p&gt;The fix is a &lt;strong&gt;TURN server&lt;/strong&gt;: a relay that carries the media when a direct path is impossible. This isn't a Metered opinion — it's LLMRTC's own docs, which state plainly that &lt;em&gt;"for production use, WebRTC requires a TURN server to ensure reliable connections for users behind NAT/firewalls"&lt;/em&gt; and recommend &lt;a href="https://www.metered.ca/tools/openrelay/" rel="noopener noreferrer"&gt;Open Relay&lt;/a&gt;, a free global TURN network with 20 GB of monthly TURN usage at no cost (&lt;a href="https://llmrtc.org" rel="noopener noreferrer"&gt;llmrtc.org&lt;/a&gt;, 2026-07-14).&lt;/p&gt;

&lt;p&gt;or you can use the &lt;a href="https://www.metered.ca/stun-turn" rel="noopener noreferrer"&gt;paid Metered TURN servers&lt;/a&gt;, if you are looking for a paid service&lt;/p&gt;

&lt;p&gt;"Production-ready" in the title; the one thing that breaks in production, omitted. If you want the networking primer it skips, this &lt;a href="https://dev.to/aprogrammer22/stun-vs-turn-vs-ice-the-webrtc-networking-explained-4jpn"&gt;STUN vs TURN vs ICE explainer&lt;/a&gt; is a solid starting point.&lt;/p&gt;

&lt;p&gt;or if you need a &lt;a href="https://www.metered.ca/blog/list-of-webrtc-ice-servers/" rel="noopener noreferrer"&gt;list of WebRTC ICE servers&lt;/a&gt;, then for your app&lt;/p&gt;

&lt;p&gt;Wiring TURN into LLMRTC is a config option — pass &lt;code&gt;iceServers&lt;/code&gt; to the web client:&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;client&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;LLMRTCWebClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;signallingUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wss://your-backend.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;iceServers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="c1"&gt;// Copy your STUN + TURN URLs and credentials from the Open Relay&lt;/span&gt;
    &lt;span class="c1"&gt;// dashboard: https://www.metered.ca/tools/openrelay/&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stun:&amp;lt;your-open-relay-stun-url&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;turn:&amp;lt;your-open-relay-turn-url&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;your-username&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;your-credential&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open Relay's 20 GB/month free tier is enough to test and ship a small agent. When you outgrow it — more concurrent users, region pinning, dynamic credentials, human support — Metered's &lt;a href="https://www.metered.ca/stun-turn" rel="noopener noreferrer"&gt;paid TURN service&lt;/a&gt; scales the same relay up to production volumes. Either way, don't ship a voice agent without one.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2F7bzf4ywiple4idjh26rh.png" class="article-body-image-wrapper"&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%2F7bzf4ywiple4idjh26rh.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I build a real-time voice AI agent in TypeScript?
&lt;/h3&gt;

&lt;p&gt;Use LLMRTC: install &lt;code&gt;@llmrtc/llmrtc-backend&lt;/code&gt; and &lt;code&gt;@llmrtc/llmrtc-web-client&lt;/code&gt; on Node 20+ (with FFmpeg). The backend runs an STT→LLM→TTS pipeline configured with provider objects and a system prompt; the browser client connects over WebRTC and manages the microphone and audio playback. Start both, and you have a working voice agent. (llmrtc.org, 2026-07-14)&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I swap providers or run the agent locally?
&lt;/h3&gt;

&lt;p&gt;Yes — LLMRTC is provider-agnostic by config. Start on OpenAI, then switch the LLM to local Ollama or LM Studio, speech-to-text to Faster-Whisper, and text-to-speech to Piper — the same agent, now fully offline and free per request. You change only the provider configuration, not your application code. (llmrtc.org, 2026-07-14)&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a TURN server for a production voice agent?
&lt;/h3&gt;

&lt;p&gt;Yes — voice-agent media is nearly all relay traffic, and users behind NAT or firewalls can't connect without one. LLMRTC's docs say WebRTC "requires a TURN server ... for users behind NAT/firewalls" and recommend Open Relay (free for 20 GB/month); configure it in your ICE servers before you ship. (llmrtc.org, 2026-07-14)&lt;/p&gt;

&lt;h3&gt;
  
  
  What does it cost to run?
&lt;/h3&gt;

&lt;p&gt;LLMRTC itself is free (Apache 2.0, no paid tier), and a fully local stack — Ollama, Faster-Whisper, Piper — costs nothing per request. Open Relay gives you 20 GB/month of free TURN. Your only spend is optional cloud provider API usage if you choose OpenAI, Anthropic, or another hosted model. (llmrtc.org, 2026-07-14)&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;You just built an AI voice agent in TypeScript that captures the mic, streams a reply over WebRTC, and handles barge-in — then ran the same agent 100% locally by changing one config block. That's provider freedom you can prove, not a bullet point.&lt;/p&gt;

&lt;p&gt;The two things to carry forward: &lt;strong&gt;STT is your latency bottleneck&lt;/strong&gt; (we measured it on both stacks), and &lt;strong&gt;you need a TURN server before real users behind firewalls can connect&lt;/strong&gt; — LLMRTC's own docs say so, and most tutorials pretend otherwise.&lt;/p&gt;

&lt;p&gt;Where to go next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Install it:&lt;/strong&gt; &lt;code&gt;npm install @llmrtc/llmrtc-backend @llmrtc/llmrtc-web-client @llmrtc/llmrtc-core&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the docs&lt;/strong&gt; at &lt;a href="https://llmrtc.org" rel="noopener noreferrer"&gt;llmrtc.org&lt;/a&gt; — start with &lt;code&gt;streamingSTT&lt;/code&gt; if you want lower latency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Star the repo&lt;/strong&gt; on &lt;a href="https://github.com/llmrtc/llmrtc" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; if this saved you an afternoon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add agent orchestration&lt;/strong&gt; (events, tools, multi-step logic) — our guide on &lt;a href="https://medium.com/@jamesbordane57/websocket-server-how-to-build-one-in-node-js-and-when-to-use-a-managed-one-13750c8d769c" rel="noopener noreferrer"&gt;building a WebSocket server in Node.js&lt;/a&gt; is the natural next step for wiring agent events.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Get the complete app
&lt;/h3&gt;

&lt;p&gt;The full, runnable demo — &lt;code&gt;server.ts&lt;/code&gt;, the browser client, and &lt;code&gt;local-bridge.ts&lt;/code&gt; — is on GitHub:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &amp;lt;repo-url-pending&amp;gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;llmrtc-voice-agent-demo
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  &lt;a href="//github.com/jamesbordane57/llmrtc-voice-agent-demo"&gt;github.com/jamesbordane57/llmrtc-voice-agent-demo&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;About the author:&lt;/strong&gt; This tutorial was written and tested by the A.L. Every latency number here was measured on my own hardware on 2026-07-14, not taken from a spec sheet.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>WebSocket Server: How to Build One in Node.js — and When to Use a Managed One</title>
      <dc:creator>alakkadshaw</dc:creator>
      <pubDate>Mon, 13 Jul 2026 20:20:57 +0000</pubDate>
      <link>https://dev.to/alakkadshaw/websocket-server-how-to-build-one-in-nodejs-and-when-to-use-a-managed-one-48c4</link>
      <guid>https://dev.to/alakkadshaw/websocket-server-how-to-build-one-in-nodejs-and-when-to-use-a-managed-one-48c4</guid>
      <description>&lt;p&gt;&lt;strong&gt;A WebSocket server is a long-lived TCP process that keeps an open, two-way connection to each client so either side can push messages the instant they happen — no polling, no re-connecting per request.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;In Node.js you can stand one up in about fifteen lines with the &lt;a href="https://github.com/websockets/ws" rel="noopener noreferrer"&gt;&lt;code&gt;ws&lt;/code&gt;&lt;/a&gt; library, and this guide builds a runnable one (broadcast, then rooms and presence) below. The harder question isn't &lt;em&gt;how&lt;/em&gt; to build one — it's whether you should run one yourself, and this guide answers that too.&lt;/p&gt;

&lt;p&gt;That fork is the whole article. If you want to own the infrastructure, the &lt;strong&gt;build&lt;/strong&gt; path is a small Node + &lt;code&gt;ws&lt;/code&gt; server you can copy-paste and run right now.&lt;/p&gt;

&lt;p&gt;If you'd rather not operate, scale, secure, and keep a fleet of stateful socket servers alive forever, the &lt;strong&gt;buy&lt;/strong&gt; path or there are free options also available like &lt;a href="https://www.metered.ca/tools/openrelay/webrtc-signaling-server/" rel="noopener noreferrer"&gt;Metered Realtime&lt;/a&gt; is a managed WebSocket service you connect to with one import. We build the small one first, so the managed one isn't a black box.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A minimal WebSocket server in Node.js is ~15 lines with &lt;code&gt;ws&lt;/code&gt; — we build broadcast, then rooms + presence, both runnable (and the same server in Python). The code is the easy 5%. The hard 95% is everything a toy server ignores: reconnection, auth, backpressure, horizontal scale across many boxes, and the reliability engineering behind five-nines uptime. &lt;strong&gt;Self-host when you need deep infrastructure control or on-prem/compliance; otherwise a managed WebSocket service is almost always the better trade&lt;/strong&gt; once you count engineering time, maintenance, and server cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Companion tutorial:&lt;/strong&gt; if your goal is specifically WebRTC, the &lt;strong&gt;&lt;a href="https://medium.com/@jamesbordane57/webrtc-signaling-server-how-it-works-build-one-node-js-or-skip-it-890e244d90ae" rel="noopener noreferrer"&gt;WebRTC Signaling Server&lt;/a&gt;&lt;/strong&gt; guide builds a signaling relay on top of exactly this pattern — a WebSocket server &lt;em&gt;is&lt;/em&gt; the transport most WebRTC signaling runs on.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="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%2F4r576rtgagoveqv2nvfc.png" class="article-body-image-wrapper"&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%2F4r576rtgagoveqv2nvfc.png" alt="A WebSocket server holds a persistent two-way connection to each browser client, pushing messages in both directions without polling." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What a WebSocket server actually is
&lt;/h2&gt;

&lt;p&gt;A normal HTTP request is one round trip: the client asks, the server answers, the connection closes. That's fine for loading a page, but it's a bad fit for anything live — chat, presence, dashboards, multiplayer, notifications — because the server can't speak until it's spoken to.&lt;/p&gt;

&lt;p&gt;A WebSocket connection is different. The client and server do a one-time HTTP "upgrade" handshake, and after that the socket stays open.&lt;/p&gt;

&lt;p&gt;Either side can send a message at any moment, in either direction, with almost no per-message overhead. That persistent, full-duplex channel is the whole value.&lt;/p&gt;

&lt;p&gt;It helps to see WebSockets next to the alternatives:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Direction&lt;/th&gt;
&lt;th&gt;Connection&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Polling / long-polling&lt;/td&gt;
&lt;td&gt;client pulls&lt;/td&gt;
&lt;td&gt;repeated HTTP requests&lt;/td&gt;
&lt;td&gt;simple, low-frequency updates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server-Sent Events (SSE)&lt;/td&gt;
&lt;td&gt;server → client only&lt;/td&gt;
&lt;td&gt;one long-lived HTTP stream&lt;/td&gt;
&lt;td&gt;one-way feeds (notifications, logs)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;WebSocket&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;both directions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;one persistent socket&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;chat, presence, multiplayer, live collaboration&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A &lt;strong&gt;WebSocket server&lt;/strong&gt;, then, is the process that accepts those upgraded connections, holds one open socket per client, and decides what to do with each incoming message — usually routing it to other clients. It's stateful (it remembers who's connected) and long-lived (it doesn't return a response and forget you), which is exactly why it's more work to operate than a stateless HTTP endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Article Contents
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/alakkadshaw/websocket-server-how-to-build-one-in-nodejs-and-when-to-use-a-managed-one-48c4#build-a-websocket-server-in-nodejs-with-raw-ws-endraw-"&gt;Build a WebSocket Server in Node.js (with ws)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/alakkadshaw/websocket-server-how-to-build-one-in-nodejs-and-when-to-use-a-managed-one-48c4#the-free-managed-option-metered-realtime-messaging"&gt;The Free Managed Option: Metered realtime Messaging&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Build a WebSocket server in Node.js (with &lt;code&gt;ws&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;ws&lt;/code&gt; library is the de-facto WebSocket implementation for Node — small, fast, zero-fuss. Here's the smallest useful server: it accepts connections and relays every message it receives to all the &lt;em&gt;other&lt;/em&gt; connected clients (a broadcast bus).&lt;/p&gt;

&lt;p&gt;Create a folder, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm pkg &lt;span class="nb"&gt;set type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;module
npm &lt;span class="nb"&gt;install &lt;/span&gt;ws
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Save this as &lt;strong&gt;&lt;code&gt;server.js&lt;/code&gt;&lt;/strong&gt;:&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="c1"&gt;// server.js — the smallest useful WebSocket server: relay every message to all other clients.&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;WebSocketServer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;WebSocket&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ws&lt;/span&gt;&lt;span class="dl"&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;PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;8080&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;wss&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;WebSocketServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PORT&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;wss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connection&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;socket&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;client connected — total:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isBinary&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="c1"&gt;// fan the message out to everyone except the sender&lt;/span&gt;
    &lt;span class="k"&gt;for &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;client&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;wss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readyState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPEN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isBinary&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;close&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;client disconnected — total:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`WebSocket server listening on ws://localhost:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Run it:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node server.js
&lt;span class="c"&gt;# WebSocket server listening on ws://localhost:8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That's a working WebSocket server. Three things are worth noticing: &lt;code&gt;wss.clients&lt;/code&gt; is a live &lt;code&gt;Set&lt;/code&gt; of every open socket, &lt;code&gt;readyState === WebSocket.OPEN&lt;/code&gt; guards against sockets mid-close (calling &lt;code&gt;.send()&lt;/code&gt; on a closing socket throws), and we skip the sender so people don't see their own messages echoed back.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2Frkawiywtvnxcmsdkvcj9.png" class="article-body-image-wrapper"&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%2Frkawiywtvnxcmsdkvcj9.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Add rooms, presence, and a heartbeat
&lt;/h3&gt;

&lt;p&gt;A single broadcast bus is rarely what you want — real apps have &lt;em&gt;rooms&lt;/em&gt; (chat channels, game lobbies, documents) and need to know who's &lt;em&gt;present&lt;/em&gt;. That means a message protocol. WebSocket frames are just bytes, so a tiny JSON envelope with a &lt;code&gt;type&lt;/code&gt; field is the standard move.&lt;/p&gt;

&lt;p&gt;Production servers also need a &lt;strong&gt;heartbeat&lt;/strong&gt; to notice sockets that died without a clean close (a common gotcha — a yanked network cable leaves a "connection" that's really gone).&lt;/p&gt;

&lt;p&gt;Save this as &lt;strong&gt;&lt;code&gt;rooms-server.js&lt;/code&gt;&lt;/strong&gt;:&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="c1"&gt;// rooms-server.js — a WebSocket server with rooms (channels), presence, a JSON protocol, and heartbeat.&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;WebSocketServer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;WebSocket&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ws&lt;/span&gt;&lt;span class="dl"&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;PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;8080&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;wss&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;WebSocketServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PORT&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// which room each socket is in&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;roomOf&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;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;broadcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;except&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;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &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;client&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;wss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;except&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readyState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPEN&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;roomOf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;wss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connection&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;socket&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;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isAlive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pong&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isAlive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invalid_json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;join&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;roomOf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;joined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;room&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
      &lt;span class="nf"&gt;broadcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;presence&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;peer-joined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&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;room&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;roomOf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;broadcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;close&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;roomOf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;broadcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;presence&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;peer-left&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;roomOf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// heartbeat: every 30s, drop any socket that didn't answer the previous ping&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heartbeat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&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="k"&gt;for &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;socket&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;wss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isAlive&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;terminate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isAlive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ping&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;wss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;close&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;heartbeat&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`WebSocket rooms server listening on ws://localhost:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;A client sends &lt;code&gt;{"type":"join","room":"lobby"}&lt;/code&gt; to enter a room, &lt;code&gt;{"type":"message","data":"…"}&lt;/code&gt; to talk to it, and everyone in the room gets &lt;code&gt;peer-joined&lt;/code&gt; / &lt;code&gt;peer-left&lt;/code&gt; presence events automatically. The heartbeat block at the bottom pings every socket every 30 seconds and terminates any that didn't pong back since the last round — that's how you reclaim dead connections. Notice it's already fiddly, and it's just &lt;em&gt;one&lt;/em&gt; of the hardening items we'll list shortly.&lt;/p&gt;
&lt;h3&gt;
  
  
  A browser client
&lt;/h3&gt;

&lt;p&gt;Save this as &lt;strong&gt;&lt;code&gt;index.html&lt;/code&gt;&lt;/strong&gt; and open it in two tabs (run &lt;code&gt;node rooms-server.js&lt;/code&gt; first):&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!doctype html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"utf-8"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"viewport"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"width=device-width, initial-scale=1"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;WebSocket chat demo&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.5&lt;/span&gt; &lt;span class="n"&gt;system-ui&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;40rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2rem&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nf"&gt;#log&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#cbd5e1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;overflow-y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nf"&gt;#log&lt;/span&gt; &lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;.1rem&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#cbd5e1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;.5rem&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#6d5efc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#fff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;pointer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;WebSocket chat&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"log"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"form"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"input"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"off"&lt;/span&gt; &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Type a message…"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Send&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;log&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;div&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
        &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&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;ws&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;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ws://localhost:8080&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;· connected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;join&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;room&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lobby&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;joined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;· joined room: &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;presence&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;· &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;peer: &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;close&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;· disconnected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

      &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;form&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;submit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&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;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;input&lt;/span&gt;&lt;span class="dl"&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;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
        &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;you: &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Type in one tab and it appears in the other. Open a third tab to watch &lt;code&gt;peer-joined&lt;/code&gt; fire. You now have a real, working WebSocket server with rooms and presence — the complete, runnable code is linked at the end.&lt;/p&gt;
&lt;h3&gt;
  
  
  The same server in Python
&lt;/h3&gt;

&lt;p&gt;The protocol is language-agnostic, so the build looks the same in any stack. Here's the broadcast server again as a &lt;code&gt;python websocket server&lt;/code&gt;, using the &lt;a href="https://websockets.readthedocs.io/" rel="noopener noreferrer"&gt;&lt;code&gt;websockets&lt;/code&gt;&lt;/a&gt; library. Save it as &lt;strong&gt;&lt;code&gt;server.py&lt;/code&gt;&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# server.py — a minimal WebSocket broadcast server in Python (websockets library).
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;websockets.asyncio.server&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;serve&lt;/span&gt;

&lt;span class="n"&gt;clients&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# fan the message out to everyone except the sender
&lt;/span&gt;            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;discard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;localhost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WebSocket server listening on ws://localhost:8080&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_running_loop&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;create_future&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# run forever
&lt;/span&gt;
&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;websockets
python server.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Same shape — accept a socket, hold it, relay to everyone else. Go (&lt;code&gt;gorilla/websocket&lt;/code&gt;) and Java (Spring's &lt;code&gt;@ServerEndpoint&lt;/code&gt;) follow the identical pattern. What changes below is &lt;em&gt;not&lt;/em&gt; the language; it's the operational load.&lt;/p&gt;
&lt;h2&gt;
  
  
  What this toy server ignores (the hard 95%)
&lt;/h2&gt;

&lt;p&gt;The code above is complete and correct — and it is nowhere near production. The forty lines are the easy part. Here's the part that isn't, and it's the same list in every language:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reconnection.&lt;/strong&gt; Networks drop. Laptops sleep, phones roam Wi-Fi→cellular, load balancers cut idle sockets. A real client needs exponential backoff with jitter; a real server needs to not fall over when thousands reconnect at once (the "thundering herd").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication &amp;amp; authorization.&lt;/strong&gt; Who is this socket? Which rooms may it join? Our server trusts anyone who connects. Production needs token auth at the handshake, per-room permission checks on every message, and an &lt;code&gt;Origin&lt;/code&gt;-header check at the upgrade — WebSockets aren't covered by the browser's same-origin policy, so without it any site can open a cross-site socket to your server (CSWSH).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backpressure.&lt;/strong&gt; A slow client whose send buffer fills up will balloon your server's memory. You have to watch &lt;code&gt;socket.bufferedAmount&lt;/code&gt; and shed or disconnect. A toy server just OOMs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Horizontal scale.&lt;/strong&gt; One process holds sockets in one &lt;code&gt;Map&lt;/code&gt; in one box's memory. The moment you need a second box, "who's in &lt;code&gt;lobby&lt;/code&gt;?" spans machines — now you need a shared pub/sub backplane (Redis, NATS) so servers can reach clients they don't personally hold.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS, presence at scale, message limits, idle timeouts, metrics, graceful deploys&lt;/strong&gt; — every one is a project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reliability.&lt;/strong&gt; This is the quiet giant. Committing to — and actually &lt;em&gt;delivering&lt;/em&gt; — five-nines (99.999%) uptime is roughly &lt;strong&gt;five minutes of downtime per year&lt;/strong&gt;, across deploys, cloud incidents, and traffic spikes. That is genuinely hard engineering, and it never ships as a weekend project.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="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%2Fru1ld8xraw92eis77pwe.png" class="article-body-image-wrapper"&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%2Fru1ld8xraw92eis77pwe.png" alt="The visible WebSocket relay code is a small tip; below the waterline sit reconnection, auth, backpressure, horizontal scale, and reliability" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Build vs buy: the honest decision
&lt;/h2&gt;

&lt;p&gt;So should you run your own WebSocket server or use a managed one? Here's the straight version, no hedging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run it yourself when you genuinely need it:&lt;/strong&gt; deep infrastructure control (custom protocols, exotic routing, data that legally cannot leave your network), or a compliance/corporate mandate for on-premise deployment. Those are real reasons, and self-hosting is the right call there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In every other case, a managed WebSocket service is usually the better trade&lt;/strong&gt; — because the forty lines were never the cost. Getting from the toy above to something you'd trust in production — auth, reconnection, backpressure, a pub/sub backplane for multiple boxes, metrics, and deploys that don't drop every connection — is realistically a few engineer-weeks up front, then a permanent line item of maintenance and on-call, plus the server bill for boxes sized to your peak concurrency. Weighed against a service with a free tier, the math rarely favors DIY.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;Self-hosted &lt;code&gt;ws&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Managed WebSocket service&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Time to first message&lt;/td&gt;
&lt;td&gt;Minutes (the code above)&lt;/td&gt;
&lt;td&gt;Minutes (an import + a key)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reconnection logic&lt;/td&gt;
&lt;td&gt;You build &amp;amp; tune it&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth / permissions&lt;/td&gt;
&lt;td&gt;You build it&lt;/td&gt;
&lt;td&gt;Built in (keys / tokens)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Horizontal scale&lt;/td&gt;
&lt;td&gt;You add a pub/sub backplane&lt;/td&gt;
&lt;td&gt;Handled for you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reliability / uptime&lt;/td&gt;
&lt;td&gt;Your on-call rotation&lt;/td&gt;
&lt;td&gt;The provider's problem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ongoing cost&lt;/td&gt;
&lt;td&gt;Eng-weeks + maintenance + VM/infra&lt;/td&gt;
&lt;td&gt;Usage-based (often a free tier)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best when&lt;/td&gt;
&lt;td&gt;You need infra control or on-prem&lt;/td&gt;
&lt;td&gt;You want to ship the product, not the plumbing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  The Free managed option: Metered Realtime Messaging
&lt;/h2&gt;

&lt;p&gt;If "buy" is the right side of that table for you, &lt;a href="https://www.metered.ca/docs/realtime-messaging/" rel="noopener noreferrer"&gt;Metered Realtime Messaging&lt;/a&gt; is a managed, high-availability WebSocket service that hands you rooms, presence, direct messages, and auth over the same JSON-over-WebSocket model we just built — minus the operations.&lt;/p&gt;

&lt;p&gt;It's deliberately &lt;strong&gt;raw JSON over WebSocket, not Socket.io&lt;/strong&gt; (so clients on any stack — browser, Node, Go, Python, Swift — can speak the wire protocol), and the browser SDK is MIT-licensed, zero-dependency, and ~13 KB gzipped. Here's the rooms-and-presence app from above, as a client against the managed service — no server for you to run:&lt;/p&gt;

&lt;p&gt;So here's the payoff. This is the &lt;strong&gt;entire&lt;/strong&gt; managed version of the chat app we just built — same UI, same room, same presence. Notice what's missing: there is no server file.&lt;/p&gt;

&lt;p&gt;Save this as &lt;strong&gt;&lt;code&gt;metered-chat.html&lt;/code&gt;&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!doctype html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"utf-8"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"viewport"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"width=device-width, initial-scale=1"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;WebSocket chat demo — managed (Metered Realtime)&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.5&lt;/span&gt; &lt;span class="n"&gt;system-ui&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;40rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2rem&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nf"&gt;#log&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#cbd5e1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;overflow-y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nf"&gt;#log&lt;/span&gt; &lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;.1rem&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#cbd5e1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;.5rem&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#6d5efc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#fff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;pointer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;WebSocket chat — managed&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"log"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"form"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"input"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"off"&lt;/span&gt; &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Type a message…"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Send&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SignallingClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://esm.sh/@metered-ca/realtime@1.1.0&lt;/span&gt;&lt;span class="dl"&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;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;log&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;div&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
        &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;

      &lt;span class="c1"&gt;// Publishable key — safe to use in the browser. Get yours free (no card):&lt;/span&gt;
      &lt;span class="c1"&gt;// https://www.metered.ca/docs/realtime-messaging/&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&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;SignallingClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pk_live_YOUR_KEY&lt;/span&gt;&lt;span class="dl"&gt;"&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;· connected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&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="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`peer: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;presence&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;left&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="k"&gt;for &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;peer&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;· peer-joined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;for &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;peer&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;· peer-left&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lobby&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;· joined room: lobby&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;form&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;submit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&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;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;input&lt;/span&gt;&lt;span class="dl"&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;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lobby&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;you: &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Serve the folder with any static server, open the page in two tabs, and type. &lt;code&gt;rooms-server.js&lt;/code&gt;, the heartbeat, the nginx block, the TLS certificates — none of it exists on your side anymore.&lt;/p&gt;

&lt;p&gt;This isn't hypothetical: we ran exactly this file against the live service while writing this guide. Two Chrome tabs connected, presence fired in both directions, and messages round-tripped A→B and B→A — with no echo to the sender, the same semantics our DIY server had.&lt;/p&gt;

&lt;p&gt;Here's what each path leaves in your repo — and on your pager:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Build (self-host)&lt;/th&gt;
&lt;th&gt;Free (managed)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Files you ship&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;rooms-server.js&lt;/code&gt; + &lt;code&gt;index.html&lt;/code&gt; + nginx config&lt;/td&gt;
&lt;td&gt;&lt;code&gt;metered-chat.html&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server process&lt;/td&gt;
&lt;td&gt;Yours, running 24/7&lt;/td&gt;
&lt;td&gt;None on your side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TLS / &lt;code&gt;wss://&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Reverse proxy + certificates&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heartbeat / dead sockets&lt;/td&gt;
&lt;td&gt;You wrote it (and maintain it)&lt;/td&gt;
&lt;td&gt;Built into the service + SDK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reconnection&lt;/td&gt;
&lt;td&gt;Still on your to-do list&lt;/td&gt;
&lt;td&gt;Automatic — exponential backoff + jitter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who gets paged&lt;/td&gt;
&lt;td&gt;You&lt;/td&gt;
&lt;td&gt;The provider&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;(Comparison as of 2026-07-13.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The endpoint is &lt;code&gt;wss://rms.metered.ca/v1&lt;/code&gt;; the &lt;code&gt;pk_live_&lt;/code&gt; key is publishable, so a no-backend prototype needs nothing else. For production you mint short-lived JWTs from a secret key on your server. On the performance question that matters for a relay, typical message-relay latency on Metered's global service is &lt;strong&gt;p50 ≈ 5 ms and p99 ≈ 20 ms&lt;/strong&gt;, with a usable connection established in roughly &lt;strong&gt;100–200 ms&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And the reliability half of the trade: Metered Realtime's uptime has historically held at &lt;strong&gt;five-nines (99.999%)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;One nice touch if you're actually doing WebRTC: the same connection can carry your signaling &lt;em&gt;and&lt;/em&gt; auto-deliver Metered TURN credentials in the connection &lt;code&gt;welcome&lt;/code&gt;, so you don't wire up a separate TURN-credential fetch. That's a signaling-specific detail covered in the &lt;a href="https://medium.com/@jamesbordane57/webrtc-signaling-server-how-it-works-build-one-node-js-or-skip-it-890e244d90ae" rel="noopener noreferrer"&gt;WebRTC signaling guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2Fx5v7npaw2a42107rztpk.png" class="article-body-image-wrapper"&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%2Fx5v7npaw2a42107rztpk.png" alt=" decision fork: self-host a ws server when you need infrastructure control or on-prem; otherwise use a managed WebSocket service" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2Fk5zj06srz7wuj3x9x5bz.png" class="article-body-image-wrapper"&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%2Fk5zj06srz7wuj3x9x5bz.png" alt=" " width="800" height="522"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The hard 95%, handled
&lt;/h2&gt;

&lt;p&gt;Earlier we listed everything the toy server ignores. Here's where each of those items lands on the managed path — this table is the real substance of the "buy" decision, so it's worth being specific.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Toy-server gap (from the iceberg)&lt;/th&gt;
&lt;th&gt;On Metered Realtime&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reconnection + thundering herd&lt;/td&gt;
&lt;td&gt;The SDK reconnects for you: exponential backoff with jitter, tunable via &lt;code&gt;ReconnectOptions&lt;/code&gt;, and close-code-aware — it won't hammer the service after a terminal error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth &amp;amp; permissions&lt;/td&gt;
&lt;td&gt;Publishable key for prototypes; production mints short-lived JWTs that scope exactly which channels a client may touch (wildcard patterns) and what it may do (&lt;code&gt;publish&lt;/code&gt; / &lt;code&gt;subscribe&lt;/code&gt; / &lt;code&gt;presence&lt;/code&gt; / &lt;code&gt;send&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backpressure &amp;amp; abuse&lt;/td&gt;
&lt;td&gt;Per-connection token bucket (100 msg/s sustained, 200 burst), 64 KB frame cap, per-IP connection limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dead-socket detection&lt;/td&gt;
&lt;td&gt;Application-level keepalive + inactivity timeout built into the SDK (default 60 s)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TLS&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;wss://&lt;/code&gt; only — nothing to configure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-box scale + uptime&lt;/td&gt;
&lt;td&gt;The provider's job — the historical five-nines figure above is what that engineering buys&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of those deserve a sentence each. The JWT model is the production path: your backend signs a short-lived token that binds a user to a stable peer ID, their allowed channels, and their permissions — and the SDK refreshes it automatically on every reconnect.&lt;/p&gt;

&lt;p&gt;And the limits aren't fine print — they're the abuse story. The free tier's limits are large and exist to stop misuse, not to squeeze prototypes into an upgrade.&lt;/p&gt;
&lt;h2&gt;
  
  
  What developers run on it
&lt;/h2&gt;

&lt;p&gt;One connection, one protocol — but teams point it at very different workloads. These are the three we see most.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live chat &amp;amp; presence.&lt;/strong&gt; Subscribe with &lt;code&gt;includeSenderMetadata: true&lt;/code&gt; and every message arrives stamped with the sender's verified identity — chat bubbles without a user-lookup round trip. The roster is just the presence events you already saw, and moderation is one REST call from your backend to force-disconnect a peer.&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;room-42&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;includeSenderMetadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fromMetadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&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="nf"&gt;addBubble&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fromMetadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;AI agents.&lt;/strong&gt; Agent swarms need exactly what a chat room needs: shared channels to coordinate, direct messages to stream results. An orchestrator publishes subtasks into a workflow channel; each agent streams its tool-call output back as direct messages; who-does-what rides in peer metadata.&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;workflow/build-42&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                       &lt;span class="c1"&gt;// every agent joins the job&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;workflow/build-42&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;summarize&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;  &lt;span class="c1"&gt;// orchestrator fans out work&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orchestratorPeerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;                  &lt;span class="c1"&gt;// agent streams results back 1:1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;IoT &amp;amp; device control.&lt;/strong&gt; Each device gets a JWT scoped to its own channels (&lt;code&gt;devices/&amp;lt;id&amp;gt;/**&lt;/code&gt;), so a compromised sensor can't touch the rest of the fleet. Devices publish telemetry and listen for commands over one socket — and your backend issues those commands with a single authenticated POST, never holding a connection at all.&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="c1"&gt;// backend: server-side publish over REST — no WebSocket needed&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://rms.metered.ca/v1/channels/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;devices/dev-17/commands&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;/publish`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SK&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reboot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;control-plane&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The same primitives cover collaborative apps — cursors and game state, with identity cached from presence and updates throttled to ~30 Hz to stay inside the rate limits — and WebRTC signaling, which the &lt;a href="https://medium.com/@jamesbordane57/webrtc-signaling-server-how-it-works-build-one-node-js-or-skip-it-890e244d90ae" rel="noopener noreferrer"&gt;companion guide&lt;/a&gt; covers end to end.&lt;/p&gt;

&lt;p&gt;For the person deciding rather than building, the short version:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metered Realtime at a glance&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SDK&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@metered-ca/realtime&lt;/code&gt; — MIT, zero dependencies, ~13 KB gzipped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free tier&lt;/td&gt;
&lt;td&gt;100 concurrent connections · 100,000 messages/month · no card&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical latency&lt;/td&gt;
&lt;td&gt;p50 ≈ 5 ms · p99 ≈ 20 ms (global service)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uptime&lt;/td&gt;
&lt;td&gt;Five-nines historically (a track record, contractual SLA available in the paid plans)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wire protocol&lt;/td&gt;
&lt;td&gt;Raw JSON over WebSocket — any language can speak it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control plane&lt;/td&gt;
&lt;td&gt;REST: mint tokens, publish server-side, kick peers, query usage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebRTC extra&lt;/td&gt;
&lt;td&gt;TURN credentials delivered automatically on connect&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="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%2Ft1pzuc2a0nrmt1u1rwee.png" class="article-body-image-wrapper"&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%2Ft1pzuc2a0nrmt1u1rwee.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How many concurrent connections can one WebSocket server handle?&lt;/strong&gt;&lt;br&gt;
A single well-tuned Node process can hold tens of thousands of mostly-idle connections; the real ceiling is memory per socket and your message rate, not a hard number. Past one box you need horizontal scale (multiple servers + a shared pub/sub layer), which is where most of the operational work lives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is &lt;code&gt;ws&lt;/code&gt; a good &lt;code&gt;socket.io&lt;/code&gt; alternative?&lt;/strong&gt;&lt;br&gt;
For most apps, yes. &lt;code&gt;socket.io&lt;/code&gt; bundles conveniences like auto-reconnect and rooms, at the cost of a heavier client and a socket.io-specific protocol on both ends; &lt;code&gt;ws&lt;/code&gt; is a lean, standards-pure WebSocket you extend as needed. If you want the reconnection and rooms &lt;code&gt;socket.io&lt;/code&gt; gives you &lt;em&gt;without&lt;/em&gt; running the server at all, that's the managed path above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need &lt;code&gt;wss://&lt;/code&gt; (TLS)?&lt;/strong&gt;&lt;br&gt;
In production, yes. Browsers block insecure &lt;code&gt;ws://&lt;/code&gt; from HTTPS pages, so you terminate TLS at a reverse proxy and serve &lt;code&gt;wss://&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I build a WebSocket server in Python / Go / Java instead of Node?&lt;/strong&gt;&lt;br&gt;
Yes — the protocol is language-agnostic. Python's &lt;code&gt;websockets&lt;/code&gt; (shown above), Go's &lt;code&gt;gorilla/websocket&lt;/code&gt;, and Java/Spring all follow the same accept-and-route shape. The build-vs-buy trade-off is identical regardless of language: the code is easy, the operations aren't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between a WebSocket server and a "WebSocket as a service"?&lt;/strong&gt;&lt;br&gt;
A WebSocket server is software you run. "WebSocket as a service" (a managed WebSocket service) is that server operated for you — connections, scaling, reconnection, and uptime become the provider's job, and you connect as a client. It's the "buy" side of this article.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What can you build on a managed WebSocket service?&lt;/strong&gt;&lt;br&gt;
Anything the DIY server would carry: live chat and presence, AI-agent coordination, IoT telemetry and device control, collaborative cursors and game state, and WebRTC signaling. The patterns above are the same three primitives — channels, presence, direct messages — pointed at different workloads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a WebSocket server the same as a WebRTC signaling server?&lt;/strong&gt;&lt;br&gt;
No, but they're related: WebRTC signaling &lt;em&gt;usually runs over&lt;/em&gt; a WebSocket server. The signaling server is a specific use of the pattern here — relaying SDP/ICE between peers. See the companion &lt;a href="https://medium.com/@jamesbordane57/webrtc-signaling-server-how-it-works-build-one-node-js-or-skip-it-890e244d90ae" rel="noopener noreferrer"&gt;WebRTC signaling guide&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Get the complete code
&lt;/h2&gt;

&lt;p&gt;The full runnable demo — &lt;code&gt;server.js&lt;/code&gt;, &lt;code&gt;rooms-server.js&lt;/code&gt;, &lt;code&gt;server.py&lt;/code&gt;, &lt;code&gt;index.html&lt;/code&gt;, the serverless &lt;code&gt;metered-chat.html&lt;/code&gt;, and a README — is in the companion repo: &lt;strong&gt;&lt;a href="https://github.com/jamesbordane57/websocket-server-demo" rel="noopener noreferrer"&gt;github.com/jamesbordane57/websocket-server-demo&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/jamesbordane57" rel="noopener noreferrer"&gt;
        jamesbordane57
      &lt;/a&gt; / &lt;a href="https://github.com/jamesbordane57/websocket-server-demo" rel="noopener noreferrer"&gt;
        websocket-server-demo
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Minimal WebSocket servers in Node.js (ws) + Python: broadcast, rooms, presence, heartbeat — companion code for the WebSocket Server tutorial
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Minimal WebSocket server (Node.js + ws)&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;Companion code for the tutorial &lt;strong&gt;"WebSocket Server: How to Build One in Node.js — and When to Use a Managed One."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;📖 &lt;strong&gt;Full tutorial:&lt;/strong&gt; &amp;lt;article-url-pending&amp;gt;&lt;/p&gt;

&lt;p&gt;Servers, from simplest to slightly-less-simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;server.js&lt;/code&gt;&lt;/strong&gt; — the smallest useful server: relays every message to all other connected clients.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rooms-server.js&lt;/code&gt;&lt;/strong&gt; — adds rooms (channels), presence (&lt;code&gt;peer-joined&lt;/code&gt; / &lt;code&gt;peer-left&lt;/code&gt;), a small JSON protocol, and a ping/pong heartbeat that reclaims dead connections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;server.py&lt;/code&gt;&lt;/strong&gt; — the same broadcast server in Python (&lt;code&gt;websockets&lt;/code&gt; library), to show the pattern is language-agnostic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus &lt;strong&gt;&lt;code&gt;index.html&lt;/code&gt;&lt;/strong&gt; — a tiny browser chat client that talks to &lt;code&gt;rooms-server.js&lt;/code&gt;.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Run it&lt;/h2&gt;
&lt;/div&gt;

&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;npm install

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; broadcast server:&lt;/span&gt;
npm start
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; …or the rooms + presence + heartbeat server:&lt;/span&gt;
npm run rooms&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Or the same broadcast server in Python:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;pip install websockets
python server.py&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Then open &lt;code&gt;index.html&lt;/code&gt; in two browser tabs (from &lt;code&gt;file://&lt;/code&gt; is fine, or &lt;code&gt;npm run&lt;/code&gt;…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/jamesbordane57/websocket-server-demo" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/jamesbordane57/websocket-server-demo.git
&lt;span class="nb"&gt;cd &lt;/span&gt;websocket-server-demo
npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm start          &lt;span class="c"&gt;# broadcast server&lt;/span&gt;
npm run rooms      &lt;span class="c"&gt;# rooms + presence + heartbeat server&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Building a WebSocket server in Node.js is genuinely easy — fifteen lines for broadcast, fifty for rooms, presence, and a heartbeat, and you saw both run (plus the same thing in Python). What's &lt;em&gt;not&lt;/em&gt; easy is everything that keeps one alive under real traffic: reconnection, auth, backpressure, multi-box scale, and five-nines reliability. Then you watched the same app run again with the server deleted.&lt;/p&gt;

&lt;p&gt;So make the call deliberately. If you need infrastructure control or on-prem, run your own — you now have the starting point.&lt;/p&gt;

&lt;p&gt;If you'd rather ship your product than operate socket servers, connect to a managed one and move on. You can &lt;a href="https://www.metered.ca/docs/realtime-messaging/" rel="noopener noreferrer"&gt;start free with Metered Realtime Messaging&lt;/a&gt; and skip the 95%.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>node</category>
      <category>javascript</category>
      <category>programming</category>
    </item>
    <item>
      <title>PeerJS vs simple-peer vs @metered-ca/realtime: 3 WebRTC Libraries Compared</title>
      <dc:creator>alakkadshaw</dc:creator>
      <pubDate>Sat, 20 Jun 2026 16:14:16 +0000</pubDate>
      <link>https://dev.to/alakkadshaw/peerjs-vs-simple-peer-vs-metered-carealtime-3-webrtc-libraries-compared-22fk</link>
      <guid>https://dev.to/alakkadshaw/peerjs-vs-simple-peer-vs-metered-carealtime-3-webrtc-libraries-compared-22fk</guid>
      <description>&lt;p&gt;&lt;a href="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%2F3os49vx3nr92kir0yso9.png" class="article-body-image-wrapper"&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%2F3os49vx3nr92kir0yso9.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PeerJS vs simple-peer comes down to one question: how much of the stack do you want to bring yourself?&lt;/strong&gt; simple-peer is a thin, elegant wrapper over a single &lt;code&gt;RTCPeerConnection&lt;/code&gt; — it makes you bring both your own signalling &lt;em&gt;and&lt;/em&gt; your own TURN. PeerJS adds a signalling broker you can use hosted or self-host, but still leaves TURN to you.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;@metered-ca/realtime&lt;/code&gt;ships managed signalling plus free TURN in the box, trading away self-hosting to do it. Pick simple-peer for a minimal 1:1 connection over signalling you already run; pick PeerJS when you need a signalling broker you can host yourself; pick &lt;code&gt;@metered-ca/realtime&lt;/code&gt; for production multi-peer where TURN and reconnection should already be handled.&lt;/p&gt;

&lt;p&gt;That's the decision in four sentences. The rest of this page earns it, because this comparison was built differently from the listicles that usually rank for "peerjs vs simple-peer": we read the source.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: PeerJS vs simple-peer is a BYO-everything question. simple-peer (9.11.1, last released Feb 2022) is a minimal 1:1 wrapper that ships no signalling and no TURN. PeerJS (1.5.5, actively maintained) ships a signalling broker — hosted or self-hosted — but no production TURN. &lt;code&gt;@metered-ca/realtime&lt;/code&gt; (1.1.0, MIT) ships managed signalling, free TURN (via Open Relay), and three-layer auto-reconnection, trading away self-hosting. Pick by constraint, not by ranking.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Real Axis: How Much Do You Bring Yourself?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="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%2Fmnuril3bm3x5k544vnjw.png" class="article-body-image-wrapper"&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%2Fmnuril3bm3x5k544vnjw.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most "PeerJS vs simple-peer" comparisons line the two up on API ergonomics — and miss the only axis that decides production outcomes. The real question is operational: of the pieces a real WebRTC app needs, how many does the library hand you, and how many do you build and operate yourself?&lt;/p&gt;

&lt;p&gt;There are three pieces that matter, and every app needs all three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signalling&lt;/strong&gt; is the rendezvous: two browsers can't connect until they've swapped SDP offers and ICE candidates through some server. &lt;strong&gt;TURN&lt;/strong&gt; is the relay that forwards media when a direct peer-to-peer path can't form — which happens constantly behind symmetric NATs and corporate firewalls. And &lt;strong&gt;reconnection&lt;/strong&gt; is what keeps a call alive when a socket dies, a laptop sleeps, or a phone hops from Wi-Fi to cellular.&lt;/p&gt;

&lt;p&gt;Line the three libraries up against those pieces and a clean spectrum appears.&lt;/p&gt;

&lt;p&gt;simple-peer brings you the connection and nothing else. You ferry its signal blobs over a WebSocket you build and operate, and you provision your own TURN. It is the most "bring it yourself" of the three, on purpose.&lt;/p&gt;

&lt;p&gt;PeerJS moves one notch along: it ships a signalling broker, hosted on its free cloud or self-hosted as PeerServer. But TURN it still leaves to you — its own docs say so, and we'll cite them below.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@metered-ca/realtime&lt;/code&gt; sits at the far end: managed signalling and free TURN both included, with reconnection handled. The trade for that is the one thing the other two give you and it doesn't — the option to self-host.&lt;/p&gt;

&lt;p&gt;Hold that spectrum in your head. Every row of the matrix below is a measurement along it.&lt;/p&gt;

&lt;h2&gt;
  
  
  PeerJS vs simple-peer vs @metered-ca/realtime: The Feature Matrix
&lt;/h2&gt;

&lt;p&gt;&lt;a href="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%2F9kvzqyditsaho3ru05bv.png" class="article-body-image-wrapper"&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%2F9kvzqyditsaho3ru05bv.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;@metered-ca/realtime&lt;/code&gt; 1.1.0&lt;/th&gt;
&lt;th&gt;PeerJS 1.5.5&lt;/th&gt;
&lt;th&gt;simple-peer 9.11.1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;License&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Built-in signalling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — managed WebSocket&lt;/td&gt;
&lt;td&gt;Yes — PeerServer broker (hosted or self-host)&lt;/td&gt;
&lt;td&gt;No — bring your own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Free TURN included&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — Open Relay (ports 80/443, TLS)&lt;/td&gt;
&lt;td&gt;No — docs say BYO for production&lt;/td&gt;
&lt;td&gt;No — BYO&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auto-reconnect (signalling)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — backoff + jitter + caps&lt;/td&gt;
&lt;td&gt;Manual &lt;code&gt;peer.reconnect()&lt;/code&gt;, single-shot&lt;/td&gt;
&lt;td&gt;n/a (no transport)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ICE restart on failure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — 9-attempt ladder (~121 s)&lt;/td&gt;
&lt;td&gt;No — closes the connection&lt;/td&gt;
&lt;td&gt;No — destroys the peer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Perfect negotiation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — polite/impolite + rollback&lt;/td&gt;
&lt;td&gt;No — rigid initiator&lt;/td&gt;
&lt;td&gt;No — rigid initiator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Multi-peer fan-out&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — &lt;code&gt;addStream()&lt;/code&gt; to a channel&lt;/td&gt;
&lt;td&gt;Manual &lt;code&gt;peer.call()&lt;/code&gt; loops&lt;/td&gt;
&lt;td&gt;No — strictly 1:1 per instance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Presence (who's online)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — &lt;code&gt;peer-joined&lt;/code&gt; / &lt;code&gt;peer-left&lt;/code&gt; events&lt;/td&gt;
&lt;td&gt;No — you distribute peer IDs yourself&lt;/td&gt;
&lt;td&gt;No (no transport)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;replaceTrack&lt;/code&gt; after connect&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — per-peer accounting&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (1:1 only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Multi-language SDKs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JS/TS, Python, Flutter/Dart&lt;/td&gt;
&lt;td&gt;JavaScript only&lt;/td&gt;
&lt;td&gt;JavaScript only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Runtime dependencies&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;7 (incl. Node polyfills)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bundle (gzipped)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~13 KB (measured; see note)&lt;/td&gt;
&lt;td&gt;29.7 KB&lt;/td&gt;
&lt;td&gt;5.2 KB own code + polyfills (see note)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Self-hostable backend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No — managed only&lt;/td&gt;
&lt;td&gt;Yes — PeerServer&lt;/td&gt;
&lt;td&gt;n/a (you build it)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two notes on the size row, because sizes are where comparison tables usually cheat. Metered first:  &lt;code&gt;@metered-ca/realtime&lt;/code&gt;, so rather than estimate, we measured the published artifact — the minified build in the npm tarball gzips to roughly 13 KB (&lt;code&gt;npm pack&lt;/code&gt;, 2026-06-12). Second, simple-peer's 5.2 KB is its &lt;em&gt;own code only&lt;/em&gt;: bundlephobia skips the Node-flavored shims it can't resolve — &lt;code&gt;buffer&lt;/code&gt;, &lt;code&gt;readable-stream&lt;/code&gt;, &lt;code&gt;randombytes&lt;/code&gt;, &lt;code&gt;queue-microtask&lt;/code&gt;, &lt;code&gt;debug&lt;/code&gt; — so the figure a real browser bundle ships is meaningfully larger than the headline number.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@metered-ca/realtime&lt;/code&gt; ships SDKs in three languages — JavaScript/TypeScript on npm, Python on PyPI, and Flutter/Dart on pub.dev — sharing one wire protocol and one signalling endpoint (&lt;a href="https://www.npmjs.com/package/@metered-ca/realtime" rel="noopener noreferrer"&gt;npm&lt;/a&gt;, 2026-06-19). PeerJS and simple-peer are both JavaScript-only.&lt;/p&gt;

&lt;p&gt;If your stack is purely browser JavaScript that difference is irrelevant; if you have a Python server or a Flutter mobile client that needs to speak the same protocol, it's the whole ballgame. We flag it as a fact, not a verdict.&lt;/p&gt;

&lt;p&gt;Now the three libraries, one at a time — and in spectrum order, from most-you-bring to most-included.&lt;/p&gt;

&lt;h2&gt;
  
  
  simple-peer: Elegant, Minimal, Last Released in 2022
&lt;/h2&gt;

&lt;p&gt;&lt;a href="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%2Fpcuns629u1beiwtg8fic.png" class="article-body-image-wrapper"&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%2Fpcuns629u1beiwtg8fic.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's a reason simple-peer keeps surfacing in the perennial "PeerJS vs simple-peer" debate years after its last release: the design is genuinely lovely. A simple-peer instance is a Node &lt;code&gt;Duplex&lt;/code&gt; stream — you &lt;code&gt;write()&lt;/code&gt; to it, you &lt;code&gt;pipe()&lt;/code&gt; it, and WebRTC suddenly behaves like every other stream in your program. If you live in the Node streams idiom, nothing else here feels as native.&lt;/p&gt;

&lt;p&gt;The whole library is one 1,052-line &lt;code&gt;index.js&lt;/code&gt; you can audit in a single sitting (&lt;a href="https://github.com/feross/simple-peer/blob/v9.11.1/index.js" rel="noopener noreferrer"&gt;&lt;code&gt;index.js&lt;/code&gt;&lt;/a&gt; at v9.11.1, read 2026-06-19). That smallness is a real virtue — there is very little between your code and the browser's &lt;code&gt;RTCPeerConnection&lt;/code&gt;, which makes it easy to reason about and easy to wrap.&lt;/p&gt;

&lt;p&gt;Minimal is a precise word, though, and you should take it literally. simple-peer ships &lt;strong&gt;no signalling&lt;/strong&gt; — you ferry its &lt;code&gt;signal&lt;/code&gt; blobs over a WebSocket you build, host, and operate. It ships &lt;strong&gt;no TURN&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It is strictly &lt;strong&gt;one connection per instance&lt;/strong&gt;, so multi-peer is your loop, your registry, and your teardown logic. And it uses the rigid initiator model — exactly one side may create offers, set by the &lt;code&gt;initiator&lt;/code&gt; flag at construction.&lt;/p&gt;

&lt;p&gt;The reconnection story is the sharpest edge. When ICE fails, simple-peer doesn't try to recover — it destroys the peer.&lt;/p&gt;

&lt;p&gt;The source is unambiguous: on &lt;code&gt;iceConnectionState === 'failed'&lt;/code&gt;, it calls &lt;code&gt;this.destroy()&lt;/code&gt; with &lt;code&gt;ERR_ICE_CONNECTION_FAILURE&lt;/code&gt;, and &lt;code&gt;restartIce()&lt;/code&gt; appears nowhere in the file (&lt;a href="https://github.com/feross/simple-peer/blob/v9.11.1/index.js" rel="noopener noreferrer"&gt;&lt;code&gt;index.js&lt;/code&gt;&lt;/a&gt; at v9.11.1, L719–720, read 2026-06-19). A network blip doesn't degrade a simple-peer connection; it ends it, and rebuilding is your job.&lt;/p&gt;

&lt;p&gt;One genuine capability PeerJS lacks: &lt;code&gt;replaceTrack&lt;/code&gt; works on simple-peer's single connection, so a 1:1 camera swap doesn't force a teardown. Credit where it's due.&lt;/p&gt;

&lt;p&gt;Then there's the calendar. Version 9.11.1 — the latest — was published on February 17, 2022, roughly 4.3 years before this article (&lt;a href="https://www.npmjs.com/package/simple-peer" rel="noopener noreferrer"&gt;npm&lt;/a&gt;, 2026-06-19).&lt;/p&gt;

&lt;p&gt;A frozen wrapper over a stable browser API doesn't simply rot — &lt;code&gt;RTCPeerConnection&lt;/code&gt; hasn't changed out from under it, and about 265,200 weekly downloads (api.npmjs.org, week ending 2026-06-18) say the ecosystem still ships it everywhere. Install base is the one thing it does not lack.&lt;/p&gt;

&lt;p&gt;But four years without a release means any bug you hit is yours to fork around. And its seven runtime dependencies include Node shims — &lt;code&gt;buffer&lt;/code&gt;, &lt;code&gt;readable-stream&lt;/code&gt;, &lt;code&gt;randombytes&lt;/code&gt; — from the era when bundlers polyfilled Node automatically. Modern bundlers mostly don't, which turns those shims into configuration you own and bundle weight the headline 5.2 KB doesn't count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick simple-peer when all four of these are true:&lt;/strong&gt; you already run signalling, you genuinely need only 1:1, you love the stream model, and you're comfortable owning whatever you hit. That's a real audience — smaller than the download count suggests, but real. If what you want is a &lt;strong&gt;simple-peer alternative&lt;/strong&gt; with signalling, reconnection, and TURN already included, that's the lane &lt;code&gt;@metered-ca/realtime&lt;/code&gt; was built for, and we'll get there.&lt;/p&gt;

&lt;p&gt;&lt;a href="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%2Fboha3vfu5l5zvgq87m5a.png" class="article-body-image-wrapper"&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%2Fboha3vfu5l5zvgq87m5a.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What PeerJS Does That simple-peer Can't
&lt;/h3&gt;

&lt;p&gt;The headline difference between the two is signalling. simple-peer makes you build it; PeerJS ships it. You can run against the free PeerJS cloud broker with no signup, or — and this is PeerJS's genuine structural advantage over both other options here — &lt;strong&gt;self-host PeerServer&lt;/strong&gt; on your own infrastructure.&lt;/p&gt;

&lt;p&gt;If compliance, air-gapping, or data-residency rules out a managed endpoint, PeerJS is the only library in this comparison that ships a broker you can run yourself. That matters enormously to the teams it matters to.&lt;/p&gt;

&lt;p&gt;PeerJS also gives you peer IDs and a friendly call API, and a decade of Stack Overflow answers behind it. Whatever error you hit, someone has Googled it before you. That mindshare is a real feature.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the Design Stops: Findings From v1.5.5
&lt;/h3&gt;

&lt;p&gt;We read the v1.5.5 &lt;code&gt;lib/&lt;/code&gt; tree on 2026-06-19. It's pleasant TypeScript — and its assumptions about networks are a decade old. Three findings matter for production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reconnection is manual and single-shot.&lt;/strong&gt; &lt;a href="https://github.com/peers/peerjs/blob/v1.5.5/lib/peer.ts" rel="noopener noreferrer"&gt;&lt;code&gt;lib/peer.ts&lt;/code&gt;&lt;/a&gt; defines &lt;code&gt;peer.reconnect()&lt;/code&gt; with no backoff, no schedule, and no retry cap — one attempt each time your code calls it. A dropped socket leaves the peer idle until you intervene, and intervening well means writing the retry loop, the jitter, and the give-up logic yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ICE failure is terminal.&lt;/strong&gt; When a connection's ICE fails, &lt;a href="https://github.com/peers/peerjs/blob/v1.5.5/lib/negotiator.ts" rel="noopener noreferrer"&gt;&lt;code&gt;lib/negotiator.ts&lt;/code&gt;&lt;/a&gt; closes it; &lt;code&gt;restartIce()&lt;/code&gt; appears nowhere in the tree. A network blip doesn't degrade a PeerJS call — it ends it, exactly as with simple-peer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Negotiation predates perfect negotiation.&lt;/strong&gt; Exactly one side may make offers (the rigid initiator model), there is no renegotiation after connect, and no &lt;code&gt;replaceTrack&lt;/code&gt; — swapping a camera mid-call means tearing the call down. Interestingly, this is the one place simple-peer is actually ahead: it supports &lt;code&gt;replaceTrack&lt;/code&gt; on its single connection, and PeerJS does not.&lt;/p&gt;

&lt;p&gt;And TURN: PeerJS's own documentation is candid that you must provide your own TURN server for peers that can't connect directly (PeerJS docs, 2026-06-19).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick PeerJS when:&lt;/strong&gt; you must self-host signalling.&lt;/p&gt;

&lt;h2&gt;
  
  
  @metered-ca/realtime: Signalling and TURN, Included
&lt;/h2&gt;

&lt;p&gt;&lt;a href="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%2Fam9vnlqkl1lrfrxnt8ar.png" class="article-body-image-wrapper"&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%2Fam9vnlqkl1lrfrxnt8ar.png" alt="Metered Realtime" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@metered-ca/realtime&lt;/code&gt; is an MIT-licensed WebRTC + realtime-messaging library: WebSocket pub/sub plus peer-to-peer WebRTC with auto-reconnect, perfect negotiation, an ICE-restart ladder, and multi-stream metadata — zero runtime dependencies, about 13 KB gzipped (npm-tarball measurement, 2026-06-12). It exists to ship the operational layer the other two options on this page leave to you. Take the three pieces from the spectrum in order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signalling is managed and free to start.&lt;/strong&gt; A &lt;code&gt;MeteredPeer&lt;/code&gt; connects to Metered's WebSocket endpoint with a publishable key for prototypes, or a server-minted JWT for production. There's nothing to deploy. This is where PeerJS is genuinely ahead in one respect — you cannot self-host this, full stop — so if self-hosting is a hard requirement, the honest answer is PeerJS, and we mean it without a wink.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TURN is in the box.&lt;/strong&gt; The stack includes &lt;a href="https://www.metered.ca/tools/openrelay/" rel="noopener noreferrer"&gt;Open Relay&lt;/a&gt;, on ports 80 and 443 with TLS — the configuration that gets media through corporate firewalls that drop plain relay traffic (Open Relay docs, 2026-06-19). Credentials can ride inside the JWT your backend mints and refresh on every reconnect, so &lt;code&gt;RTCPeerConnection&lt;/code&gt; gets working &lt;code&gt;iceServers&lt;/code&gt; without you maintaining relay config or babysitting expiring secrets. This is the single biggest difference from both PeerJS and simple-peer: they both make you solve TURN; here it's already solved for prototype and hobby workloads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reconnection has three layers, and you write none of them.&lt;/strong&gt; The signalling WebSocket retries with exponential backoff — 500 ms doubling toward a 30-second ceiling, jittered, close-code-aware, capped at 100 attempts by default. Beneath that, a failed ICE connection triggers a restart ladder — up to nine attempts over roughly two minutes, surfaced to your UI as a clean &lt;code&gt;reconnecting&lt;/code&gt; state — the layer that saves a call when a phone roams from Wi-Fi to cellular. And when the socket comes back, channel reconciliation swaps a fresh &lt;code&gt;RTCPeerConnection&lt;/code&gt;, with fresh TURN credentials, &lt;em&gt;inside the same &lt;code&gt;RemotePeer&lt;/code&gt; object&lt;/em&gt;, so the peer references your UI state holds stay valid — no teardown handlers, no flicker, no peer-list reset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Presence is built in, and multi-peer is the default shape.&lt;/strong&gt; A &lt;code&gt;MeteredPeer&lt;/code&gt; joins a channel; &lt;code&gt;peer-joined&lt;/code&gt; and &lt;code&gt;peer-left&lt;/code&gt; events tell you who's online, so the roster is an event handler rather than a subsystem you design. From there, &lt;code&gt;peer.addStream()&lt;/code&gt; fans your media out to everyone — no &lt;code&gt;peer.call()&lt;/code&gt; loop, no hand-maintained registry. Swapping a camera mid-call is &lt;code&gt;peer.replaceTrack(oldTrack, newTrack)&lt;/code&gt; with no renegotiation. Under all of it sits the W3C perfect-negotiation pattern — polite/impolite roles with rollback — so simultaneous offers resolve instead of colliding.&lt;/p&gt;

&lt;p&gt;Here is the working core of a group video call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MeteredPeer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@metered-ca/realtime&lt;/span&gt;&lt;span class="dl"&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;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;MeteredPeer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;tokenProvider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetchJwt&lt;/span&gt;&lt;span class="p"&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;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;peer-joined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;remote&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;remote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stream-added&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;attachToVideoTile&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;localStream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;addStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStream&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;room-42&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No call loops, no peer registry, no reconnect handler. The snippet isn't a teaser — it's the architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick X When: The Decision in Plain Constraints
&lt;/h2&gt;

&lt;p&gt;By constraint, not by ranking. Read down until a line matches your situation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You already run signalling and only need a thin 1:1 connection&lt;/strong&gt; → &lt;strong&gt;simple-peer&lt;/strong&gt;. The stream model is a pleasure and the surface area is tiny. Go in knowing the last release was February 2022 and ICE failure destroys the peer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted signalling is non-negotiable&lt;/strong&gt; (compliance, air-gapping, data residency) → &lt;strong&gt;PeerJS&lt;/strong&gt;. The only option here that ships a broker you can run yourself, and it's maintained.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A prototype between two laptops&lt;/strong&gt; → &lt;strong&gt;PeerJS&lt;/strong&gt; again. The hosted cloud broker is the lowest-friction no-backend start in this comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production multi-peer on real-world networks&lt;/strong&gt; — calls that must survive NATs, sleep/wake cycles, and Wi-Fi-to-cellular hops → &lt;strong&gt;&lt;code&gt;@metered-ca/realtime&lt;/code&gt;&lt;/strong&gt;. TURN, reconnection, presence, and fan-out come included; the trade is managed-only signalling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need Python or Flutter clients on the same protocol as your browser code&lt;/strong&gt; → &lt;strong&gt;&lt;code&gt;@metered-ca/realtime&lt;/code&gt;&lt;/strong&gt;. It's the only one of the three that isn't JavaScript-only.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="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%2Fbhx71yg4efyolz9f9el1.png" class="article-body-image-wrapper"&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%2Fbhx71yg4efyolz9f9el1.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the spectrum from the top of the article, turned into an operations bill. Count the pieces you'd run yourself in production for each library — not write once, but &lt;em&gt;operate&lt;/em&gt;, monitor, and pay for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With simple-peer, you operate four things.&lt;/strong&gt; A signalling server (build it, host it, scale it). A TURN relay — &lt;a href="https://www.metered.ca/blog/coturn/" rel="noopener noreferrer"&gt;coturn&lt;/a&gt; on ports 80/443 with TLS certs and bandwidth bills, or a commercial relay billed by the gigabyte. A reconnection strategy, because ICE failure destroys the peer and rebuilding is on you. And a multi-peer registry, because each instance is strictly 1:1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With PeerJS, you operate three.&lt;/strong&gt; Signalling comes included — that's the upgrade over simple-peer. But TURN is still yours to provision and pay for, reconnection is still your &lt;code&gt;peer.reconnect()&lt;/code&gt; loop to write, and multi-peer is still your &lt;code&gt;peer.call()&lt;/code&gt; loop and registry to maintain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With &lt;code&gt;@metered-ca/realtime&lt;/code&gt;, you operate close to zero of them&lt;/strong&gt;. Signalling is managed. TURN ships in the box through Open Relay, on the firewall-friendly ports. Reconnection is the three-layer ladder you write none of. Presence and fan-out replace the registry and the call loop.&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>api</category>
    </item>
    <item>
      <title>PeerJS alternatives in 2026- free TURN, auto-reconnect, and which WebRTC library to actually pick.</title>
      <dc:creator>alakkadshaw</dc:creator>
      <pubDate>Fri, 12 Jun 2026 20:33:13 +0000</pubDate>
      <link>https://dev.to/alakkadshaw/peerjs-alternatives-in-2026-free-turn-auto-reconnect-and-which-webrtc-library-to-actually-pick-14ad</link>
      <guid>https://dev.to/alakkadshaw/peerjs-alternatives-in-2026-free-turn-auto-reconnect-and-which-webrtc-library-to-actually-pick-14ad</guid>
      <description>&lt;p&gt;The best PeerJS alternative in 2026 is &lt;strong&gt;&lt;a href="https://www.metered.ca/tools/openrelay/webrtc-library" rel="noopener noreferrer"&gt;@metered-ca/realtime&lt;/a&gt;&lt;/strong&gt; for most production peer-to-peer apps: &lt;/p&gt;

&lt;p&gt;it is the only MIT-licensed WebRTC library in this comparison that ships signalling, free TURN relay (20 GB/month via Open Relay), and automatic reconnection in one package.&lt;/p&gt;

&lt;p&gt;That's the short answer. The long one is worth your time, because this comparison was built differently&lt;/p&gt;

&lt;p&gt;We read the source. Earlier this month we sat down with the published code of all three libraries — PeerJS 1.5.5, a 28-file TypeScript tree; simple-peer 9.11.1, a single 1,052-line &lt;code&gt;index.js&lt;/code&gt;; and &lt;code&gt;@metered-ca/realtime&lt;/code&gt; 1.0.8 — and traced what each one actually does when a socket dies, an offer collides, or a camera needs replacing mid-call. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: For most production P2P apps, &lt;code&gt;@metered-ca/realtime&lt;/code&gt; is the strongest PeerJS alternative — free TURN, three-layer automatic reconnection, built-in presence, channel fan-out, zero dependencies, ~13 KB gzipped. Pick PeerJS if you must self-host signalling; it is maintained, not dead. Pick simple-peer for a minimal 1:1 wrapper over your own signalling. For large rooms or broadcast, no P2P library fits — use an SFU.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo60jstzolo8tbznx4h1j.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fo60jstzolo8tbznx4h1j.png" alt=" " width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Developers Go Looking for a PeerJS Alternative
&lt;/h2&gt;

&lt;p&gt;Nobody leaves PeerJS because of its API. The API is the best thing about it: &lt;code&gt;new Peer()&lt;/code&gt;, &lt;code&gt;peer.call(id, stream)&lt;/code&gt;, a working video call in minutes with no backend.&lt;/p&gt;

&lt;p&gt;Developers start searching for a PeerJS alternative when the demo meets a production network — and it is almost always one of three walls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The TURN wall comes first.&lt;/strong&gt; Most comparisons will tell you PeerJS ships no TURN at all.&lt;/p&gt;

&lt;p&gt;So, you need a TURN service to handle the connections across firewalls and NATs&lt;/p&gt;

&lt;p&gt;So the wall stands where it always did. The demo works at home; then someone joins from an office network, the connection quietly fails. Your realistic options: get something like &lt;a href="https://www.metered.ca/tools/openrelay/" rel="noopener noreferrer"&gt;openrelayproject.org&lt;/a&gt;, &lt;a href="https://www.metered.ca/blog/coturn/" rel="noopener noreferrer"&gt;operate coturn yourself&lt;/a&gt; — ports 80/443, TLS certificates, bandwidth bills — or pay a commercial relay by the gigabyte like &lt;a href="https://www.metered.ca/stun-turn" rel="noopener noreferrer"&gt;Metered TURN service&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reconnect gap shows up second.&lt;/strong&gt; Networks blink. Laptops sleep. Phones hop from Wi-Fi to cellular in the middle of a sentence. PeerJS's whole answer is &lt;code&gt;peer.reconnect()&lt;/code&gt; — a method you call manually, one attempt per call, with no retry schedule behind it. We'll show you exactly where in the source below. Until your code notices the drop and intervenes, the peer sits idle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scaling question arrives last.&lt;/strong&gt; PeerJS thinks in point-to-point calls: you know a remote ID, you dial it. A four-person call is a loop of calls plus a peer registry you maintain by hand. And when the shared free broker stops being appropriate for production, PeerJS's own docs point you at running PeerServer yourself — another service to deploy, scale, and monitor, and precisely the backend the "no server needed" pitch let you skip.&lt;/p&gt;

&lt;p&gt;None of these are bugs. They're scope. PeerJS draws its line at the API, and everything operational past that line belongs to you. &lt;/p&gt;

&lt;h2&gt;
  
  
  PeerJS Alternatives at a Glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;@metered-ca/realtime&lt;/code&gt; 1.0.8&lt;/th&gt;
&lt;th&gt;PeerJS 1.5.5&lt;/th&gt;
&lt;th&gt;simple-peer 9.11.1&lt;/th&gt;
&lt;th&gt;Raw &lt;code&gt;RTCPeerConnection&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;License&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;Browser API (no library)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Built-in signalling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — managed WebSocket&lt;/td&gt;
&lt;td&gt;Yes — PeerServer broker, but uptime is not great&lt;/td&gt;
&lt;td&gt;No — bring your own&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Free TURN included&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — Open Relay, 20 GB/mo (ports 80/443, TLS)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No — BYO&lt;/td&gt;
&lt;td&gt;No — BYO&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auto-reconnect (signalling)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — backoff + jitter + caps&lt;/td&gt;
&lt;td&gt;Manual &lt;code&gt;peer.reconnect()&lt;/code&gt;, single-shot&lt;/td&gt;
&lt;td&gt;n/a (no transport)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ICE restart on failure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — 9-attempt ladder (~121 s)&lt;/td&gt;
&lt;td&gt;No — closes the connection&lt;/td&gt;
&lt;td&gt;No — destroys the peer&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Perfect negotiation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — polite/impolite + rollback&lt;/td&gt;
&lt;td&gt;No — rigid initiator&lt;/td&gt;
&lt;td&gt;No — rigid initiator&lt;/td&gt;
&lt;td&gt;Your code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Multi-peer fan-out&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — &lt;code&gt;addStream()&lt;/code&gt; to a channel&lt;/td&gt;
&lt;td&gt;Manual &lt;code&gt;peer.call()&lt;/code&gt; loops&lt;/td&gt;
&lt;td&gt;No — strictly 1:1&lt;/td&gt;
&lt;td&gt;Your code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Presence (who's online)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — &lt;code&gt;peer-joined&lt;/code&gt; / &lt;code&gt;peer-left&lt;/code&gt; events&lt;/td&gt;
&lt;td&gt;No — you distribute peer IDs yourself&lt;/td&gt;
&lt;td&gt;No (no transport)&lt;/td&gt;
&lt;td&gt;No — your signalling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auth / channel permissions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JWT — per-channel patterns + permission scopes&lt;/td&gt;
&lt;td&gt;None hosted; single shared &lt;code&gt;key&lt;/code&gt; self-hosted&lt;/td&gt;
&lt;td&gt;n/a (no transport)&lt;/td&gt;
&lt;td&gt;Your code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;replaceTrack&lt;/code&gt; after connect&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — per-peer accounting&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (1:1 only)&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Runtime dependencies&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;7 (incl. Node polyfills)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bundle (gzipped)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~13 KB (measured; see note)&lt;/td&gt;
&lt;td&gt;29.7 KB&lt;/td&gt;
&lt;td&gt;5.2 KB own code + polyfills (see note)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Self-hostable backend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No — managed only&lt;/td&gt;
&lt;td&gt;Yes — PeerServer&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  1. &lt;a href="https://www.metered.ca/tools/openrelay/webrtc-library" rel="noopener noreferrer"&gt;@metered-ca/realtime&lt;/a&gt;: The Operational Layer, Included
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@metered-ca/realtime&lt;/code&gt; is an MIT-licensed JavaScript/TypeScript WebRTC library: WebSocket pub/sub plus peer-to-peer WebRTC with auto-reconnect, perfect negotiation, an ICE-restart ladder, and multi-stream metadata — zero runtime dependencies, about 13 KB gzipped (npm tarball measurement, 2026-06-12). It exists to ship the operational layer every other option on this page leaves to you. Take the three walls from earlier, in order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TURN is in the box.&lt;/strong&gt; The library's stack includes &lt;a href="https://www.metered.ca/tools/openrelay/" rel="noopener noreferrer"&gt;Open Relay&lt;/a&gt;, Metered's TURN service with a free 20 GB/month tier — static credentials for prototyping, REST-issued credentials for production, or rotating credentials delivered inside the JWT your backend already mints and re-fetched on every reconnect, so &lt;code&gt;RTCPeerConnection&lt;/code&gt; gets working &lt;code&gt;iceServers&lt;/code&gt; without you maintaining relay config or babysitting expiring secrets. The relays listen on ports 80 and 443 with TLS — what gets media through corporate firewalls that drop plain relay traffic (Open Relay docs, 2026-06-12). That's the production-grade version of what PeerJS's best-effort defaults gesture at, and it's usually the difference between "works in the demo" and "works from a hospital guest network."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reconnection has three layers, and you write none of them.&lt;/strong&gt; The signalling WebSocket retries with exponential backoff — 500 ms doubling toward a 30-second ceiling, jittered, close-code-aware, capped at 100 attempts by default — so a broken auth path stops with a definite error instead of hammering forever. Beneath that, a failed ICE connection triggers a restart ladder — up to nine attempts over roughly two minutes, surfaced to your UI as a clean &lt;code&gt;reconnecting&lt;/code&gt; state — the layer that saves a call when a phone roams from Wi-Fi to cellular and every address changes mid-sentence. And when the socket comes back, channel reconciliation re-subscribes your channels and swaps a fresh &lt;code&gt;RTCPeerConnection&lt;/code&gt; — with fresh TURN credentials — &lt;em&gt;inside the same &lt;code&gt;RemotePeer&lt;/code&gt; object&lt;/em&gt;, so the peer references your React state holds stay valid. No teardown-and-rebuild handlers, no flicker, no peer-list reset. We published a tutorial that kills the network mid-call so you can &lt;a href="https://dev.to/alakkadshaw/webrtc-reconnect-auto-heal-a-call-metered-capeer-36hh"&gt;watch a call heal itself&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Presence is built in, and multi-peer is the default shape.&lt;/strong&gt; A &lt;code&gt;MeteredPeer&lt;/code&gt; joins a channel; &lt;code&gt;peer-joined&lt;/code&gt; and &lt;code&gt;peer-left&lt;/code&gt; events tell you who's online, so the roster is an event handler rather than a subsystem you design. (PeerJS is candid that this part is your job — "You're in charge of communicating the peer IDs between users of your site," says its getting-started guide, 2026-06-12.) From there, &lt;code&gt;peer.addStream()&lt;/code&gt; fans your media out to everyone. Swapping a camera mid-call is &lt;code&gt;peer.replaceTrack(oldTrack, newTrack)&lt;/code&gt; — no renegotiation — and if the swap half-fails across peers, you get a typed error with explicit &lt;code&gt;succeeded&lt;/code&gt; and &lt;code&gt;failed&lt;/code&gt; lists instead of silent inconsistency. Under all of it sits the W3C perfect-negotiation pattern, polite/impolite roles with rollback, so either side can renegotiate at any time and simultaneous offers resolve instead of colliding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And access is something you can actually scope.&lt;/strong&gt; The JWT your backend mints is the whole permission model: &lt;code&gt;channels&lt;/code&gt; patterns control which channels the token may touch, a &lt;code&gt;permissions&lt;/code&gt; list controls what it may do there — publish, subscribe, presence, send — and the same token carries peer identity and TURN credentials. PeerJS has nothing comparable, by design rather than negligence — its broker is open to any client with any free ID, and we unpack what that means in the PeerJS section below. Typed errors and pluggable logging round out the operational layer.&lt;/p&gt;

&lt;p&gt;Here is the working core of a group video call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MeteredPeer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@metered-ca/realtime&lt;/span&gt;&lt;span class="dl"&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;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;MeteredPeer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;tokenProvider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetchJwt&lt;/span&gt;&lt;span class="p"&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;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;peer-joined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;remote&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;remote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stream-added&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;attachToVideoTile&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;localStream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;addStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStream&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;room-42&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No call loops, no peer registry, no reconnect handler. The snippet isn't a teaser — it is the architecture. (For a complete runnable build, see the &lt;a href="https://dev.to/aprogrammer22/webrtc-video-call-tutorial-11-video-chat-in-js-with-metered-peer-4m1e"&gt;video-call tutorial&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;Coming from PeerJS? The &lt;a href="https://www.metered.ca/docs/realtime-messaging/sdk-javascript/migration/from-peerjs/" rel="noopener noreferrer"&gt;official migration guide&lt;/a&gt; maps every concept across — and there's a condensed version of it later in this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. PeerJS
&lt;/h2&gt;

&lt;p&gt;a donation-funded community service with no SLA, shared with everyone else on the defaults — PeerJS's own cloud page warns that manually-set IDs may collide and asks high-traffic applications to host their own PeerServer (&lt;a href="https://peerjs.com/server/cloud" rel="noopener noreferrer"&gt;peerjs.com&lt;/a&gt;, 2026-06-12). When the broker has gone down, users have found out through the issue tracker — "&lt;a href="https://github.com/peers/peerjs/issues/941" rel="noopener noreferrer"&gt;0.peerjs.com server down&lt;/a&gt;" (April 2022), with similar threads in &lt;a href="https://github.com/peers/peerjs/issues/851" rel="noopener noreferrer"&gt;2021&lt;/a&gt; and &lt;a href="https://github.com/peers/peerjs/issues/671" rel="noopener noreferrer"&gt;2020&lt;/a&gt; — though the project now runs a public &lt;a href="https://status.peerjs.com" rel="noopener noreferrer"&gt;status page&lt;/a&gt;, to its credit.&lt;/p&gt;

&lt;p&gt;We read all 28 files of the v1.5.5 &lt;code&gt;lib/&lt;/code&gt; tree on 2026-06-04. It's pleasant TypeScript — and its assumptions about networks are a decade old. Four findings matter for production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reconnection is manual and single-shot.&lt;/strong&gt; &lt;a href="https://github.com/peers/peerjs/blob/v1.5.5/lib/peer.ts" rel="noopener noreferrer"&gt;&lt;code&gt;lib/peer.ts&lt;/code&gt;&lt;/a&gt; defines &lt;code&gt;peer.reconnect()&lt;/code&gt; with no backoff, no schedule, and no retry cap — one attempt each time your code calls it. A dropped socket leaves the peer idle until you intervene, and intervening well means writing the retry loop, the jitter, and the give-up logic yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ICE failure is terminal.&lt;/strong&gt; When a connection's ICE fails, &lt;a href="https://github.com/peers/peerjs/blob/v1.5.5/lib/negotiator.ts" rel="noopener noreferrer"&gt;&lt;code&gt;lib/negotiator.ts&lt;/code&gt;&lt;/a&gt; closes it; &lt;code&gt;restartIce()&lt;/code&gt; appears nowhere in the tree. A network blip doesn't degrade a PeerJS call — it ends it, and your app rebuilds from scratch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Negotiation predates perfect negotiation.&lt;/strong&gt; Exactly one side may make offers (the rigid initiator model), and a collision between simultaneous offers is handled by string-matching the error message rather than by rollback. There is no renegotiation after connect and no &lt;code&gt;replaceTrack&lt;/code&gt; — swapping a camera mid-call means tearing the call down — and only the first remote stream (&lt;code&gt;streams[0]&lt;/code&gt;) is surfaced; multi-stream isn't part of the model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is no auth model — the connection "token" is &lt;code&gt;Math.random().toString(36)&lt;/code&gt;.&lt;/strong&gt; To be fair about what that means: it's a reconnect nonce, not an authentication credential, and PeerJS doesn't claim otherwise. But the picture is consistent across the stack: the hosted broker is open to any client claiming any free ID, a self-hosted PeerServer authenticates everyone with one shared &lt;code&gt;key&lt;/code&gt; string (&lt;a href="https://peerjs.com/server/getting-started" rel="noopener noreferrer"&gt;PeerServer docs&lt;/a&gt;, 2026-06-12), and nothing in the model expresses per-user identity or per-channel permissions. Fine for a demo; just don't mistake any of it for auth when you sketch your security model.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All four are observations from the published source of v1.5.5 (read 2026-06-04, re-checked 2026-06-12) and PeerJS's current docs — not complaints harvested from an issue tracker.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. simple-peer: Elegant, Minimal, Last Released in 2022
&lt;/h2&gt;

&lt;p&gt;There's a reason simple-peer keeps coming up in the perennial "PeerJS vs simple-peer" debate years after its last release: the design is genuinely lovely. A simple-peer instance is a Node &lt;code&gt;Duplex&lt;/code&gt; stream — you &lt;code&gt;write()&lt;/code&gt; to it, you &lt;code&gt;pipe()&lt;/code&gt; it, and WebRTC suddenly behaves like every other stream in your program. Our own API is event-based, and we won't pretend the stream model isn't the nicer abstraction for piping data. The entire library is one 1,052-line &lt;code&gt;index.js&lt;/code&gt; you can audit in a sitting, and that smallness is a real virtue.&lt;/p&gt;

&lt;p&gt;Minimal is a precise word, though, and you should take it literally. simple-peer ships no signalling — you ferry its &lt;code&gt;signal&lt;/code&gt; blobs over a WebSocket you build and operate. It ships no TURN. It is strictly one connection per instance, so multi-peer is your loop, your registry, your teardown logic. It uses the same rigid initiator model as PeerJS. And when ICE fails, the peer destroys itself with &lt;code&gt;ERR_ICE_CONNECTION_FAILURE&lt;/code&gt; — no restart, no retry (&lt;a href="https://github.com/feross/simple-peer/blob/v9.11.1/index.js" rel="noopener noreferrer"&gt;&lt;code&gt;index.js&lt;/code&gt;&lt;/a&gt; at v9.11.1, read 2026-06-04). One genuine capability PeerJS lacks: &lt;code&gt;replaceTrack&lt;/code&gt; works on its single connection, so a 1:1 camera swap doesn't force a teardown.&lt;/p&gt;

&lt;p&gt;Then there's the calendar. Version 9.11.1 — the latest — was published on February 17, 2022, roughly 4.3 years before this article (&lt;a href="https://www.npmjs.com/package/simple-peer" rel="noopener noreferrer"&gt;npm&lt;/a&gt;, 2026-06-12). A frozen wrapper over a stable browser API doesn't simply rot; &lt;code&gt;RTCPeerConnection&lt;/code&gt; hasn't changed out from under it, and ~266,400 weekly downloads (api.npmjs.org, week ending 2026-06-11) say the ecosystem still ships it everywhere — install base is one thing it does not lack. But four years without a release means any bug you hit is yours to fork around. And its seven runtime dependencies include Node shims — &lt;code&gt;buffer&lt;/code&gt;, &lt;code&gt;readable-stream&lt;/code&gt;, &lt;code&gt;randombytes&lt;/code&gt; — from the era when bundlers polyfilled Node automatically. Modern bundlers mostly don't, which turns those shims into configuration you own and into bundle weight the headline 5.2 KB doesn't count.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Raw &lt;code&gt;RTCPeerConnection&lt;/code&gt;:
&lt;/h2&gt;

&lt;p&gt;Every library in this comparison is a wrapper around &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API" rel="noopener noreferrer"&gt;&lt;code&gt;RTCPeerConnection&lt;/code&gt;&lt;/a&gt;, the browser's native WebRTC API — so "no library at all" is always on the table. It costs zero bytes and hides nothing.&lt;/p&gt;

&lt;p&gt;It also hands you the entire bill. You design a signalling protocol and run its server. You implement negotiation — &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Perfect_negotiation" rel="noopener noreferrer"&gt;MDN's perfect negotiation pattern&lt;/a&gt; is the canonical reference, and the subtleties it exists to solve (glare, rollback, role asymmetry) are exactly the ones that bite in production. You provision TURN, watch ICE states, write the restart logic, rebuild dropped connections, and manage every peer pairwise.&lt;/p&gt;

&lt;p&gt;Our honest take: every WebRTC developer should wire the raw API end-to-end once, because nothing else makes the libraries' trade-offs legible. Teams with unusual requirements or a hard no-dependency rule should ship it. Everyone else ends up rebuilding, slowly and in production, the operational layer this article has been describing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating From PeerJS: What Actually Changes
&lt;/h2&gt;

&lt;p&gt;One mental shift carries the whole migration: PeerJS is point-to-point — you know a remote ID and you dial it — while &lt;code&gt;@metered-ca/realtime&lt;/code&gt; is channel-based — both sides join a named channel, discovery happens through presence events, and media fans out to the membership. Most of the code you delete is the code that managed that difference by hand.&lt;/p&gt;

&lt;p&gt;The mapping, condensed from the &lt;a href="https://www.metered.ca/docs/realtime-messaging/sdk-javascript/migration/from-peerjs/" rel="noopener noreferrer"&gt;official migration guide&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You write in PeerJS&lt;/th&gt;
&lt;th&gt;You write in &lt;code&gt;@metered-ca/realtime&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;What changed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;new Peer("alice")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JWT with &lt;code&gt;sub: "alice"&lt;/code&gt;, minted server-side&lt;/td&gt;
&lt;td&gt;Stable IDs come from auth, not the constructor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;peer.call(remoteId, stream)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;peer.addStream(stream)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fans out to every channel peer; no per-target calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;peer.on("call", call =&amp;gt; call.answer(stream))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;peer.on("peer-joined", ({ peer: remote }) =&amp;gt; …)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No explicit answer step; both sides attach streams&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;peer.reconnect()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Reconnection is automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;peer.disconnect()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Lifecycle is managed; &lt;code&gt;peer.close()&lt;/code&gt; is terminal teardown only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Mapping source: the official PeerJS → &lt;code&gt;@metered-ca/realtime&lt;/code&gt; migration guide (metered.ca docs, 2026-06-12).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three porting pitfalls to know before you start. &lt;code&gt;peer.sendTo()&lt;/code&gt; rejects with &lt;code&gt;peer_not_found&lt;/code&gt; when the target is offline — PeerJS queued data for you, so add presence-awareness wherever you relied on that. &lt;code&gt;peer.close()&lt;/code&gt; is terminal: construct a fresh instance rather than reusing one, where PeerJS let you &lt;code&gt;disconnect()&lt;/code&gt; and come back. And never cache the underlying &lt;code&gt;RTCPeerConnection&lt;/code&gt; or &lt;code&gt;MediaStream&lt;/code&gt; objects — both are replaced across reconnects (the peer reference and &lt;code&gt;stream.id&lt;/code&gt; are stable; re-bind your &lt;code&gt;&amp;lt;video&amp;gt;.srcObject&lt;/code&gt; from each &lt;code&gt;stream-added&lt;/code&gt; event, which re-fires after a reconcile).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fohci6csrgt52pmlrsj1v.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fohci6csrgt52pmlrsj1v.png" alt=" " width="800" height="1131"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the best PeerJS alternative in 2026?
&lt;/h3&gt;

&lt;p&gt;For most production peer-to-peer apps, &lt;code&gt;@metered-ca/realtime&lt;/code&gt; is the strongest PeerJS alternative: free TURN via Open Relay (20 GB/month), three-layer automatic reconnection, perfect negotiation, built-in presence, and channel-based fan-out in one MIT package with zero dependencies. PeerJS remains right for self-hosted signalling; simple-peer for minimal 1:1 wrappers over signalling you already run.&lt;/p&gt;

&lt;h3&gt;
  
  
  PeerJS vs simple-peer: which should I use?
&lt;/h3&gt;

&lt;p&gt;PeerJS ships a signalling broker — hosted or self-hosted — and a friendly &lt;code&gt;Peer&lt;/code&gt; API, so it's faster to start. simple-peer ships no signalling at all but wraps one connection in an elegant Node &lt;code&gt;Duplex&lt;/code&gt; stream. Pick PeerJS for batteries-included brokering; pick simple-peer if you already run signalling and want a thin 1:1 wrapper — noting its last release was February 2022.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does PeerJS include a TURN server?
&lt;/h3&gt;

&lt;p&gt;No, peerJs does not include a turn server&lt;/p&gt;

&lt;h3&gt;
  
  
  Is there a simple-peer alternative with built-in signalling?
&lt;/h3&gt;

&lt;p&gt;Yes — &lt;code&gt;@metered-ca/realtime&lt;/code&gt; is the closest simple-peer alternative with signalling included. simple-peer deliberately ships no transport: you ferry its signal blobs over your own WebSocket and write your own reconnect logic. &lt;code&gt;@metered-ca/realtime&lt;/code&gt; handles the WebSocket, automatic reconnection, an ICE-restart ladder, and TURN, while staying a small, single-class, MIT-licensed API.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I migrate from PeerJS to @metered-ca/realtime?
&lt;/h3&gt;

&lt;p&gt;Follow the &lt;a href="https://www.metered.ca/docs/realtime-messaging/sdk-javascript/migration/from-peerjs/" rel="noopener noreferrer"&gt;official migration guide&lt;/a&gt;. The core shift is conceptual: per-target &lt;code&gt;peer.call(remoteId)&lt;/code&gt; becomes a channel both sides join, with &lt;code&gt;peer.addStream()&lt;/code&gt; fanning media to every member, and manual &lt;code&gt;peer.reconnect()&lt;/code&gt; simply disappears — reconnection is automatic. The guide includes the full API mapping table, side-by-side code, and a porting checklist.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>webrtc</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>WebRTC Signaling Server: How It Works, Build One (Node.js), or Skip It</title>
      <dc:creator>alakkadshaw</dc:creator>
      <pubDate>Thu, 04 Jun 2026 22:24:32 +0000</pubDate>
      <link>https://dev.to/alakkadshaw/webrtc-signaling-server-how-it-works-build-one-nodejs-or-skip-it-d84</link>
      <guid>https://dev.to/alakkadshaw/webrtc-signaling-server-how-it-works-build-one-nodejs-or-skip-it-d84</guid>
      <description>&lt;p&gt;&lt;strong&gt;A WebRTC signaling server is the matchmaker that lets two browsers find each other and exchange the connection details (SDP offers/answers + ICE candidates) needed to open a direct peer-to-peer link — it relays those handshake messages but never touches your audio or video, which flow browser-to-browser once the handshake completes.&lt;/strong&gt; WebRTC deliberately leaves &lt;em&gt;how&lt;/em&gt; you move those messages up to you; this guide shows the two real paths: &lt;strong&gt;build your own&lt;/strong&gt; minimal signaling server in Node.js (runnable, below), or &lt;strong&gt;skip it entirely&lt;/strong&gt; with free managed signaling and no server to run.&lt;/p&gt;

&lt;p&gt;That fork is the whole article. If you want to understand the moving parts and own the infrastructure, the &lt;strong&gt;build&lt;/strong&gt; path is a ~40-line Node + &lt;a href="https://github.com/websockets/ws" rel="noopener noreferrer"&gt;&lt;code&gt;ws&lt;/code&gt;&lt;/a&gt; relay plus a raw &lt;code&gt;RTCPeerConnection&lt;/code&gt; browser client — copy-paste-able and tested. If you'd rather not run, scale, secure, and reconnect a WebSocket server forever, the &lt;strong&gt;buy&lt;/strong&gt; path connects to free managed signaling (&lt;code&gt;https://www.npmjs.com/package/@metered-ca/realtime&lt;/code&gt;) with one import and a publishable key. We'll build the small one first so the managed one isn't a black box.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Companion tutorials:&lt;/strong&gt; once you have signaling working, the &lt;strong&gt;&lt;a href="https://dev.to/aprogrammer22/webrtc-video-call-tutorial-11-video-chat-in-js-with-metered-peer-4m1e"&gt;WebRTC video-call tutorial&lt;/a&gt;&lt;/strong&gt; builds the full 1:1 call on top of it, and the &lt;strong&gt;WebRTC reconnect tutorial&lt;/strong&gt; shows how to make a call survive a network drop — the single hardest thing the minimal server below ignores.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What a WebRTC signaling server does
&lt;/h2&gt;

&lt;p&gt;WebRTC gives two browsers a way to talk &lt;em&gt;directly&lt;/em&gt; — peer-to-peer audio, video, or data — without routing every packet through your servers. But before that direct link can exist, the two peers have to agree on a pile of details neither of them knows about the other: codecs, encryption keys, and the network addresses (host, reflexive, relayed) at which each can be reached. Discovering and exchanging that information is &lt;strong&gt;signaling&lt;/strong&gt;, and the thing that carries those messages between the two peers is a &lt;strong&gt;signaling server&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Concretely, a WebRTC signaling server does three jobs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Peer discovery&lt;/strong&gt; — it's how peer A learns that peer B exists and wants to connect (a room, a call ID, a channel). Browsers have no way to find each other on the open internet; the signaling server is the rendezvous point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relays the SDP offer/answer&lt;/strong&gt; — each side produces a &lt;strong&gt;Session Description Protocol (SDP)&lt;/strong&gt; blob describing what it can send/receive (codecs, resolutions, encryption fingerprints). One peer sends an &lt;em&gt;offer&lt;/em&gt;, the other replies with an &lt;em&gt;answer&lt;/em&gt;. The signaling server just passes these between them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relays ICE candidates&lt;/strong&gt; — as each browser discovers the network paths it can be reached on (via STUN, and TURN when needed), it emits &lt;strong&gt;ICE candidates&lt;/strong&gt;. The signaling server forwards each candidate to the other peer so the two can find a route that works.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is the part people miss: &lt;strong&gt;the signaling server never touches your media.&lt;/strong&gt; Once the SDP exchange and ICE negotiation finish, the audio/video/data flows &lt;strong&gt;directly between the two browsers&lt;/strong&gt; (or through a TURN relay if a direct path is impossible — but never through the signaling server). The signaling server's whole job is the &lt;em&gt;handshake&lt;/em&gt;. After the call is connected, it can disconnect and the call keeps running.&lt;/p&gt;

&lt;p&gt;That's why a signaling server can be tiny: it's a message &lt;em&gt;relay&lt;/em&gt;, not a media server. It moves a few kilobytes of JSON at call setup and then gets out of the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  How WebRTC signaling works (the offer/answer/ICE dance)
&lt;/h2&gt;

&lt;p&gt;The signaling sequence for a 1:1 connection is always the same shape, regardless of what transport you pick:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Both peers connect to the signaling server and join the same room.&lt;/li&gt;
&lt;li&gt;One peer creates an &lt;strong&gt;offer&lt;/strong&gt; (&lt;code&gt;pc.createOffer()&lt;/code&gt; → &lt;code&gt;setLocalDescription&lt;/code&gt;) and sends the SDP to the other peer through the server.&lt;/li&gt;
&lt;li&gt;The other peer applies it (&lt;code&gt;setRemoteDescription&lt;/code&gt;), creates an &lt;strong&gt;answer&lt;/strong&gt; (&lt;code&gt;createAnswer()&lt;/code&gt; → &lt;code&gt;setLocalDescription&lt;/code&gt;), and sends that SDP back through the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In parallel&lt;/strong&gt;, each peer's &lt;code&gt;RTCPeerConnection&lt;/code&gt; fires &lt;code&gt;icecandidate&lt;/code&gt; events as it discovers network paths. Each candidate is relayed to the other peer, which adds it with &lt;code&gt;addIceCandidate&lt;/code&gt;. (This is &lt;strong&gt;trickle ICE&lt;/strong&gt; — candidates flow continuously instead of waiting for a complete list.)&lt;/li&gt;
&lt;li&gt;ICE picks a working candidate pair, the connection goes to &lt;code&gt;connected&lt;/code&gt;, and &lt;strong&gt;media flows directly&lt;/strong&gt; between the browsers. Signaling's job is done.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  WebRTC doesn't define the transport — WebSocket is the common pick
&lt;/h3&gt;

&lt;p&gt;Crucially, &lt;strong&gt;the WebRTC spec does not say how signaling messages travel.&lt;/strong&gt; It standardizes the &lt;em&gt;content&lt;/em&gt; (SDP, ICE candidates) and the browser API (&lt;code&gt;RTCPeerConnection&lt;/code&gt;), but the channel that moves those messages is entirely your choice. You could relay them over:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WebSocket&lt;/strong&gt; — by far the most common, because signaling is inherently bidirectional and low-latency (the server must push B's offer to A the instant it arrives). This is what we'll build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP long-polling / SSE / &lt;code&gt;fetch&lt;/code&gt;&lt;/strong&gt; — workable, clunkier for the server-push direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything else&lt;/strong&gt; — even a shared database or a copy-paste of the SDP by hand works for a demo. The browser doesn't care; it just needs the other peer's SDP and candidates to arrive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because WebSocket is the natural fit, the canonical "build a signaling server" task is really "stand up a small WebSocket relay." Let's do exactly that.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv8qctr0ngk3jq25aq49h.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fv8qctr0ngk3jq25aq49h.png" alt="02-offer-answer-ice-sequence" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a minimal WebRTC signaling server (Node.js + ws)
&lt;/h2&gt;

&lt;p&gt;This is the DIY path: &lt;strong&gt;raw WebRTC + a raw WebSocket relay&lt;/strong&gt;. No SDK. The goal is the smallest thing that genuinely connects two tabs — one room, two peers — so you can see every moving part. Two files: a Node server (&lt;code&gt;signaling-server.js&lt;/code&gt;) and a browser page (&lt;code&gt;index.html&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node.js 18+&lt;/strong&gt; and &lt;strong&gt;npm&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;One npm package for the server: &lt;a href="https://github.com/websockets/ws" rel="noopener noreferrer"&gt;&lt;code&gt;ws&lt;/code&gt;&lt;/a&gt; (the de-facto Node WebSocket library).&lt;/li&gt;
&lt;li&gt;A modern browser: &lt;strong&gt;Chrome 90+ / Firefox 90+ / Safari 15+&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;getUserMedia&lt;/code&gt; needs a &lt;strong&gt;secure context&lt;/strong&gt; — &lt;strong&gt;HTTPS or &lt;code&gt;localhost&lt;/code&gt;&lt;/strong&gt;. Serve the page; don't open it as &lt;code&gt;file://&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  1. The signaling server (&lt;code&gt;signaling-server.js&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;The entire server is a WebSocket relay: it accepts up to two peers into one room and forwards each message it receives to the &lt;em&gt;other&lt;/em&gt; peer. It never parses the SDP or ICE inside — it just moves bytes. It also tells each peer whether it's the "polite" one, which the client uses for &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Perfect_negotiation" rel="noopener noreferrer"&gt;perfect negotiation&lt;/a&gt; (so both tabs can run identical code without their offers colliding), and it sends a one-word &lt;code&gt;ready&lt;/code&gt; nudge to the first peer the moment the second one joins — so neither side starts the offer/answer dance until there's actually someone on the other end.&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="c1"&gt;// signaling-server.js — a minimal WebRTC signaling server (Node + ws).&lt;/span&gt;
&lt;span class="c1"&gt;// It relays signaling messages between the two peers in one room, and tells each&lt;/span&gt;
&lt;span class="c1"&gt;// peer whether it is the "polite" one (for perfect negotiation). It NEVER sees your&lt;/span&gt;
&lt;span class="c1"&gt;// audio/video — media flows peer-to-peer once ICE finishes.&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;WebSocketServer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ws&lt;/span&gt;&lt;span class="dl"&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;PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8080&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;wss&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;WebSocketServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PORT&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;room&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;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// one room, at most two peers — enough to prove the concept&lt;/span&gt;

&lt;span class="nx"&gt;wss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connection&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;socket&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1013&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;room full&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 1013 = "try again later"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// First peer in is "impolite", second is "polite" (perfect-negotiation tie-break).&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;polite&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;welcome&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;polite&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`peer connected as &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;polite&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;polite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;impolite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/2)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Once BOTH peers are present, tell the peer that was already waiting it can start&lt;/span&gt;
  &lt;span class="c1"&gt;// negotiating. Without this, the first peer would offer into an empty room (that offer&lt;/span&gt;
  &lt;span class="c1"&gt;// is lost) and then ignore the second peer's offer as a "collision" — a deadlock.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &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;peer&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;peer&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readyState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPEN&lt;/span&gt;&lt;span class="p"&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;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Relay every other message to the OTHER peer. The server doesn't parse the SDP or&lt;/span&gt;
  &lt;span class="c1"&gt;// ICE inside — offer, answer, or candidate, it just forwards the bytes.&lt;/span&gt;
  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isBinary&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="k"&gt;for &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;peer&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;peer&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readyState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPEN&lt;/span&gt;&lt;span class="p"&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;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isBinary&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;close&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`peer disconnected (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/2)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Signaling server listening on ws://localhost:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That's the whole signaling server. Notice what's &lt;em&gt;not&lt;/em&gt; there: no SDP parsing, no media handling, no understanding of WebRTC at all. To this server, an offer, an answer, and an ICE candidate are identical — opaque JSON it forwards to the one other peer in the room.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. The browser client (&lt;code&gt;index.html&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;The client is raw WebRTC: a single &lt;code&gt;RTCPeerConnection&lt;/code&gt;, with the offer/answer/ICE messages sent over the WebSocket above. It uses the standard &lt;strong&gt;perfect negotiation&lt;/strong&gt; pattern so both tabs can run the exact same code — whichever the server marked "polite" yields if both happen to offer at once. Two ordering details matter for it to actually connect: it grabs the camera &lt;em&gt;before&lt;/em&gt; wiring up the socket (so the only &lt;code&gt;await&lt;/code&gt; happens first and no early &lt;code&gt;welcome&lt;/code&gt;/offer message is missed), and it doesn't add its tracks — which is what kicks off the offer — until it knows the other peer is present (via &lt;code&gt;welcome&lt;/code&gt;'s polite flag or the server's &lt;code&gt;ready&lt;/code&gt; nudge), so it never offers into an empty room.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"utf-8"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"viewport"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"width=device-width, initial-scale=1"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;Minimal WebRTC signaling — DIY&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;system-ui&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nt"&gt;video&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;320px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin-right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;Minimal WebRTC signaling (raw RTCPeerConnection + ws)&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;video&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"local"&lt;/span&gt; &lt;span class="na"&gt;autoplay&lt;/span&gt; &lt;span class="na"&gt;playsinline&lt;/span&gt; &lt;span class="na"&gt;muted&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/video&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;video&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"remote"&lt;/span&gt; &lt;span class="na"&gt;autoplay&lt;/span&gt; &lt;span class="na"&gt;playsinline&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/video&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="c1"&gt;// client — runs in the BROWSER. Raw RTCPeerConnection + the ws relay.&lt;/span&gt;
      &lt;span class="c1"&gt;// Canonical "perfect negotiation" pattern, trimmed to the minimum:&lt;/span&gt;
      &lt;span class="c1"&gt;// both tabs run identical code; the server told us which one is "polite".&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SIGNALING_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ws://localhost:8080&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="c1"&gt;// Get the camera FIRST. This is the only `await` in the script, so doing it up front&lt;/span&gt;
      &lt;span class="c1"&gt;// means every handler below is registered synchronously — no early signaling message&lt;/span&gt;
      &lt;span class="c1"&gt;// (the "welcome", or the very first offer) can arrive before we're listening for it.&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;localStream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;local&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;srcObject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="c1"&gt;// Public STUN only. Behind a real / symmetric NAT you ALSO need TURN here:&lt;/span&gt;
      &lt;span class="c1"&gt;//   iceServers: [{ urls: "stun:..." }, { urls: "turn:...", username, credential }]&lt;/span&gt;
      &lt;span class="c1"&gt;// Delivering those TURN credentials is one of the jobs a managed signaling server does for you.&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pc&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="na"&gt;iceServers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stun:stun.l.google.com:19302&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;polite&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// set from the server's "welcome"&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;makingOffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;ignoreOffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="c1"&gt;// Remote media shows up here once the connection is live.&lt;/span&gt;
      &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ontrack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;streams&lt;/span&gt;&lt;span class="p"&gt;:&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;remote&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;srcObject&lt;/span&gt; &lt;span class="o"&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="c1"&gt;// Trickle ICE: send each local candidate over the relay as we discover it.&lt;/span&gt;
      &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onicecandidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;candidate&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ice&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;

      &lt;span class="c1"&gt;// Whenever we need to (re)negotiate, make an offer. The "polite" peer backs off&lt;/span&gt;
      &lt;span class="c1"&gt;// if both sides offer at once, so this same handler is safe in both tabs.&lt;/span&gt;
      &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onnegotiationneeded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;makingOffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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;pc&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="c1"&gt;// implicit createOffer()&lt;/span&gt;
          &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;description&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localDescription&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;makingOffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;

      &lt;span class="c1"&gt;// Add our tracks (which fires `negotiationneeded` and kicks off the offer) only once&lt;/span&gt;
      &lt;span class="c1"&gt;// BOTH peers are in the room — so we never offer into an empty room.&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;started&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;for &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;track&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;localStream&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="nx"&gt;pc&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;localStream&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;ws&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;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SIGNALING_URL&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;send&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

      &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;data&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;welcome&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;polite&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;polite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
          &lt;span class="c1"&gt;// If we're the polite (second) peer, the other peer is already here — safe to&lt;/span&gt;
          &lt;span class="c1"&gt;// negotiate now. The impolite (first) peer instead waits for "ready" below.&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;polite&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
          &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="c1"&gt;// We're the first peer and a second just joined — now both are present.&lt;/span&gt;
          &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
          &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;description&lt;/span&gt;&lt;span class="dl"&gt;"&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;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&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;offerCollision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
            &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;offer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;makingOffer&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signalingState&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stable&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

          &lt;span class="nx"&gt;ignoreOffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;polite&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;offerCollision&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// impolite peer ignores the colliding offer&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ignoreOffer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;pc&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;description&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;offer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&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;pc&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="c1"&gt;// implicit createAnswer()&lt;/span&gt;
            &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;description&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localDescription&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ice&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;try&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;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addIceCandidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ignoreOffer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                     &lt;span class="c1"&gt;// candidates for an ignored offer are expected to fail&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Run it
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. New folder, install the one dependency:&lt;/span&gt;
npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm pkg &lt;span class="nb"&gt;set type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;module          &lt;span class="c"&gt;# so the server can use `import`&lt;/span&gt;
npm &lt;span class="nb"&gt;install &lt;/span&gt;ws

&lt;span class="c"&gt;# 2. Start the signaling server:&lt;/span&gt;
node signaling-server.js
&lt;span class="c"&gt;# -&amp;gt; Signaling server listening on ws://localhost:8080&lt;/span&gt;

&lt;span class="c"&gt;# 3. In another terminal, serve index.html over http (NOT file://):&lt;/span&gt;
npx serve &lt;span class="nb"&gt;.&lt;/span&gt;                       &lt;span class="c"&gt;# or: python3 -m http.server 8000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Now open the served page in &lt;strong&gt;two browser tabs&lt;/strong&gt; (e.g. &lt;code&gt;http://localhost:3000&lt;/code&gt; from &lt;code&gt;serve&lt;/code&gt;, or &lt;code&gt;http://localhost:8000&lt;/code&gt;). The first tab shows your camera; when the second tab loads, the two tabs run the offer/answer/ICE handshake through your server, and &lt;strong&gt;each tab's second video tile fills with the other tab's camera&lt;/strong&gt;. In the server terminal you'll see &lt;code&gt;peer connected as impolite (1/2)&lt;/code&gt; then &lt;code&gt;peer connected as polite (2/2)&lt;/code&gt;. You just built a working WebRTC signaling server.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv4urfzqq8ddxkzp57551.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fv4urfzqq8ddxkzp57551.png" alt="Browser tab titled " width="800" height="221"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And here are the two tabs once the handshake completes — each tab's second tile is filled by the &lt;strong&gt;other&lt;/strong&gt; tab's camera, which is the proof the call connected peer-to-peer:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5uuadxmu76diuhk0djpl.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2F5uuadxmu76diuhk0djpl.png" alt=" " width="760" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fotri7h8tano9pnxt50ij.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fotri7h8tano9pnxt50ij.png" alt=" " width="760" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why two tabs work locally but a real call may not:&lt;/strong&gt; on one machine, both peers are on &lt;code&gt;localhost&lt;/code&gt;, so STUN alone finds a direct path. Put the two peers on &lt;em&gt;different real networks&lt;/em&gt; (especially behind symmetric NAT or a corporate firewall) and a direct path often doesn't exist — you'll need &lt;strong&gt;TURN&lt;/strong&gt; to relay the media, and your signaling server has to &lt;em&gt;deliver TURN credentials&lt;/em&gt; to each client. The minimal server above does none of that. More on this next.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Get the complete app
&lt;/h3&gt;

&lt;p&gt;Don't want to stitch the two snippets together by hand? The whole project — &lt;code&gt;signaling-server.js&lt;/code&gt;, &lt;code&gt;index.html&lt;/code&gt;, a &lt;code&gt;package.json&lt;/code&gt;, and a &lt;code&gt;README&lt;/code&gt; — is one gist you can clone and run or fork it:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/jamesbordane57" rel="noopener noreferrer"&gt;
        jamesbordane57
      &lt;/a&gt; / &lt;a href="https://github.com/jamesbordane57/webrtc-signaling-server-demo" rel="noopener noreferrer"&gt;
        webrtc-signaling-server-demo
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Minimal WebRTC signaling server (Node + &lt;code&gt;ws&lt;/code&gt;)&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;Companion code for the tutorial &lt;strong&gt;WebRTC Signaling Server: How It Works, Build One, or Skip It&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📖 &lt;strong&gt;Full tutorial:&lt;/strong&gt; &lt;a href="https://dev.to/alakkadshaw/webrtc-signaling-server-how-it-works-build-one-nodejs-or-skip-it-d84" rel="nofollow"&gt;WebRTC Signaling Server: How It Works, Build One, or Skip It&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two files, ~40 lines of server: a WebSocket relay (&lt;code&gt;signaling-server.js&lt;/code&gt;) that forwards SDP
offer/answer + ICE candidates between two peers in one room, plus a raw &lt;code&gt;RTCPeerConnection&lt;/code&gt;
browser client (&lt;code&gt;index.html&lt;/code&gt;) using the standard &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Perfect_negotiation" rel="nofollow noopener noreferrer"&gt;perfect-negotiation&lt;/a&gt;
pattern. The server &lt;strong&gt;never touches your media&lt;/strong&gt; — audio/video flows peer-to-peer once ICE finishes.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Run it&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; 1. Install the one dependency:&lt;/span&gt;
npm install

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; 2. Start the signaling server:&lt;/span&gt;
npm start
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; -&amp;gt; Signaling server listening on ws://localhost:8080&lt;/span&gt;

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; 3. In another terminal, serve index.html over http (NOT file://):&lt;/span&gt;
npm run serve            &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; serves on http://localhost:3000&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; or: python3 -m http.server 8000&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Open the served page in &lt;strong&gt;two browser tabs&lt;/strong&gt; (e.g. &lt;code&gt;http://localhost:3000&lt;/code&gt;…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/jamesbordane57/webrtc-signaling-server-demo" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/jamesbordane57/webrtc-signaling-server-demo.git
&lt;span class="nb"&gt;cd &lt;/span&gt;webrtc-signaling-server-demo
npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm start                 &lt;span class="c"&gt;# signaling server on ws://localhost:8080&lt;/span&gt;
&lt;span class="c"&gt;# then, in a second terminal:&lt;/span&gt;
npx serve &lt;span class="nb"&gt;.&lt;/span&gt;               &lt;span class="c"&gt;# serve index.html — open http://localhost:3000 in two tabs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What this minimal version does NOT handle
&lt;/h3&gt;

&lt;p&gt;The ~40-line relay above proves the concept, but it is nowhere near production. Here's the gap — i.e. the real cost of the DIY path — roughly in the order it'll bite you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;More than two peers / real rooms.&lt;/strong&gt; It's a single hard-coded room capped at two sockets. Real apps need room creation/joining, room IDs, capacity, and routing a message to the &lt;em&gt;right&lt;/em&gt; peers in the &lt;em&gt;right&lt;/em&gt; room (not just "the other socket").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconnection.&lt;/strong&gt; WebSockets drop — Wi-Fi blips, laptop sleep, cellular handoff. This server has no reconnect logic, no backoff, no session resumption. When the socket dies mid-call, signaling is simply gone, and the raw &lt;code&gt;RTCPeerConnection&lt;/code&gt; won't recover the media path on its own either (no ICE restart). Hand-rolling resilient reconnection is the single hardest part of DIY signaling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication &amp;amp; authorization.&lt;/strong&gt; Anyone who can reach the WebSocket can join any room and receive its signaling traffic. There's no auth, no per-room access control, no rate limiting, no abuse protection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TURN credential delivery.&lt;/strong&gt; As noted above, real-world calls need TURN, and TURN needs &lt;em&gt;short-lived credentials&lt;/em&gt; delivered to each client securely (you don't hard-code TURN secrets in client JS). That's a backend responsibility your signaling layer normally owns — and it's entirely absent here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Presence.&lt;/strong&gt; Who's online? Who just left? Who's in this room right now? There's no roster, no join/leave events surfaced to the app beyond the two console logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scale &amp;amp; ops.&lt;/strong&gt; One Node process, in-memory room state, no horizontal scaling, no health checks, no metrics, no deployment story. Two processes behind a load balancer immediately breaks the in-memory &lt;code&gt;room&lt;/code&gt; Set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wire-format hardening.&lt;/strong&gt; No message validation, no max-size limits, no protection against malformed frames.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are exotic — they're table stakes for a signaling server you'd put real users on. Building and &lt;em&gt;maintaining&lt;/em&gt; them is the actual cost of "just build a signaling server." Which is the whole reason the second path exists.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9crk1ybk12r1azhn1atp.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2F9crk1ybk12r1azhn1atp.png" alt="DIY-gap-checklist" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Or skip it: free managed signaling, zero server to run
&lt;/h2&gt;

&lt;p&gt;Here's the path the search results barely cover: you don't have to run a signaling server at all. &lt;strong&gt;Managed signaling&lt;/strong&gt; means the WebSocket relay, rooms, reconnection, auth, and TURN credential delivery are operated for you — you connect a client and skip every gap from the previous section.&lt;/p&gt;

&lt;p&gt;Most hosted real-time options bundle this inside a broader &lt;strong&gt;CPaaS&lt;/strong&gt; (Communications-Platform-as-a-Service) product, and the genuinely free, "just point at our endpoint" tier is rare. One that's free for prototypes and hobby work is &lt;strong&gt;&lt;a href="https://www.npmjs.com/package/@metered-ca/realtime" rel="noopener noreferrer"&gt;&lt;code&gt;https://www.npmjs.com/package/@metered-ca/realtime&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; (an MIT-licensed, zero-dependency JS/TS library, ~13 KB gzipped with WebRTC) talking to Metered's managed signaling endpoint at &lt;strong&gt;&lt;code&gt;wss://rms.metered.ca/v1&lt;/code&gt;&lt;/strong&gt;. There's no server for you to deploy, scale, or keep alive.&lt;/p&gt;

&lt;p&gt;Here's the &lt;em&gt;entire&lt;/em&gt; signaling+call client — the managed equivalent of everything above, in one HTML file. Both tabs run identical code; joining the same channel &lt;strong&gt;is&lt;/strong&gt; the handshake.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"utf-8"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"viewport"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"width=device-width, initial-scale=1"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;Managed WebRTC signaling — @metered-ca/realtime&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;system-ui&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nt"&gt;video&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;320px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin-right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;Managed WebRTC signaling (no server to run)&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;idle&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;video&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"local"&lt;/span&gt; &lt;span class="na"&gt;autoplay&lt;/span&gt; &lt;span class="na"&gt;playsinline&lt;/span&gt; &lt;span class="na"&gt;muted&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/video&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;video&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"remote"&lt;/span&gt; &lt;span class="na"&gt;autoplay&lt;/span&gt; &lt;span class="na"&gt;playsinline&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/video&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MeteredPeer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@metered-ca/realtime&lt;/span&gt;&lt;span class="dl"&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;CHANNEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;demo-room&lt;/span&gt;&lt;span class="dl"&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;PK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pk_live_REPLACE_ME&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;--&lt;/span&gt; &lt;span class="nx"&gt;your&lt;/span&gt; &lt;span class="nx"&gt;publishable&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="nx"&gt;metered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ca&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;statusEl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="c1"&gt;// 1. Capture camera + mic (HTTPS or localhost, same as raw WebRTC).&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;localStream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;local&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;srcObject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="c1"&gt;// 2. One peer, one channel — publishable-key auth, no token server.&lt;/span&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;MeteredPeer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PK&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="c1"&gt;// 3. When another peer joins, listen for THEIR media on the remote peer object.&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;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;peer-joined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;remote&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;remote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stream-added&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;remote&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;srcObject&lt;/span&gt; &lt;span class="o"&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="c1"&gt;// Built-in reconnect signal — read `to` for the new state.&lt;/span&gt;
        &lt;span class="nx"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;state-change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&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;statusEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// e.g. "reconnecting" -&amp;gt; "connected"&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="c1"&gt;// 4. Publish our camera to everyone in the channel (no per-target call).&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;addStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;camera&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="c1"&gt;// 5. Join — this is what actually connects. Managed signaling handles SDP/ICE,&lt;/span&gt;
      &lt;span class="c1"&gt;//    perfect negotiation, TURN credential delivery, and reconnection for you.&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CHANNEL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;statusEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;joined &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;CHANNEL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To run it: grab a free &lt;strong&gt;publishable key&lt;/strong&gt; (&lt;code&gt;pk_live_…&lt;/code&gt;) by signing up at &lt;a href="https://www.metered.ca/" rel="noopener noreferrer"&gt;metered.ca&lt;/a&gt;, paste it in for &lt;code&gt;PK&lt;/code&gt;, serve the file over &lt;code&gt;localhost&lt;/code&gt; (same as before — &lt;code&gt;getUserMedia&lt;/code&gt; needs a secure context), and open two tabs. There is &lt;strong&gt;no &lt;code&gt;node&lt;/code&gt; process, no &lt;code&gt;ws&lt;/code&gt;, no &lt;code&gt;signaling-server.js&lt;/code&gt;&lt;/strong&gt; — the managed endpoint is the signaling server.&lt;/p&gt;

&lt;p&gt;What you got "for free" relative to the DIY build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No server to deploy or maintain.&lt;/strong&gt; The signaling relay, rooms, and scaling are operated for you at &lt;code&gt;wss://rms.metered.ca/v1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconnection is built in.&lt;/strong&gt; The SDK auto-recovers from WebSocket drops and runs an ICE-restart ladder, preserving the same remote-peer identity across the blip — the hardest DIY gap, handled. (See the reconnect companion tutorial.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Channel-based peer discovery + presence.&lt;/strong&gt; &lt;code&gt;peer.join(channel)&lt;/code&gt; discovers peers and fires &lt;code&gt;peer-joined&lt;/code&gt; / &lt;code&gt;peer-left&lt;/code&gt; — no manual roster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TURN credential delivery.&lt;/strong&gt; Metered can auto-inject TURN credentials into the connection, and its &lt;a href="https://www.metered.ca/tools/openrelay/" rel="noopener noreferrer"&gt;Open Relay&lt;/a&gt; project provides free TURN bandwidth for prototypes — so calls that need a relay (symmetric NAT, corporate firewalls) work without you wiring TURN by hand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Managed signaling gives you two ways to authenticate, mirroring the "start simple, harden later" path:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Publishable key (&lt;code&gt;pk_live_…&lt;/code&gt;)&lt;/strong&gt; — what's used above. Zero backend; the key goes straight in the browser. Each connection gets a random peer ID. Ideal for prototypes, static sites, and public demo channels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;tokenProvider&lt;/code&gt; (JWT)&lt;/strong&gt; — for production. Your backend mints a short-lived HS256 JWT (signed with a secret key) and the SDK fetches it on connect &lt;em&gt;and&lt;/em&gt; on every reconnect. This gives you stable per-user peer IDs, peer-visible metadata, and embedded TURN credentials. Swap the constructor:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Production: your backend mints a JWT; the SDK refreshes it automatically on reconnect.&lt;/span&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;MeteredPeer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;tokenProvider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetchJwtFromYourBackend&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest of the call code is identical — only the auth line changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build vs. buy: when to self-host signaling, when to use managed
&lt;/h2&gt;

&lt;p&gt;Neither path is universally right. Honest framing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;
&lt;strong&gt;Build your own&lt;/strong&gt; (raw &lt;code&gt;ws&lt;/code&gt; + &lt;code&gt;RTCPeerConnection&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;
&lt;strong&gt;Managed signaling&lt;/strong&gt; (&lt;code&gt;@metered-ca/realtime&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best when&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You need full control of the wire, custom routing/auth, on-prem/air-gapped deploys, or you're learning WebRTC end-to-end&lt;/td&gt;
&lt;td&gt;You want a working call fast and don't want to operate signaling infra&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;You operate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The WebSocket server, rooms, reconnection, auth, TURN delivery, scaling, monitoring&lt;/td&gt;
&lt;td&gt;Nothing — the endpoint is managed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reconnection&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You build the ladder (WS backoff + ICE restart + media re-attach)&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TURN credentials&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You deliver them yourself&lt;/td&gt;
&lt;td&gt;Auto-injected; free Open Relay tier for prototypes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Self-hosting&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes — it's your server&lt;/td&gt;
&lt;td&gt;No — connects only to &lt;code&gt;wss://rms.metered.ca/v1&lt;/code&gt; (the trade for zero setup)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cost shape&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Your server + bandwidth + ops time&lt;/td&gt;
&lt;td&gt;Free tier for prototypes/hobby; usage-based beyond&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Time to first call&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hours-to-days (production-grade)&lt;/td&gt;
&lt;td&gt;Minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is a WebRTC signaling server required?&lt;/strong&gt;&lt;br&gt;
Yes — WebRTC has no built-in peer discovery, so two browsers can't find each other or exchange SDP/ICE without &lt;em&gt;some&lt;/em&gt; signaling channel between them. What's &lt;em&gt;not&lt;/em&gt; required is that you build it: a &lt;a href="https://www.npmjs.com/package/@metered-ca/realtime" rel="noopener noreferrer"&gt;managed signaling endpoint&lt;/a&gt; satisfies the requirement with no server of your own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you do WebRTC without a signaling server?&lt;/strong&gt;&lt;br&gt;
Only in the trivial sense that "signaling" can be anything that moves the SDP and ICE candidates between peers — you could copy-paste them by hand for a demo, or relay them over an existing channel. For any real app you need a signaling mechanism; you just don't have to &lt;em&gt;run&lt;/em&gt; one if you use managed signaling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there a free WebRTC signaling server?&lt;/strong&gt;&lt;br&gt;
Free &lt;em&gt;self-host&lt;/em&gt; options exist (you run them — the Node + &lt;code&gt;ws&lt;/code&gt; relay in this guide is one, and there are open-source projects on GitHub). Free &lt;em&gt;managed&lt;/em&gt; signaling — where someone else runs it — is rarer; &lt;code&gt;@metered-ca/realtime&lt;/code&gt; offers a free tier on &lt;code&gt;wss://rms.metered.ca/v1&lt;/code&gt; via a publishable key, with no server for you to operate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the signaling server free with &lt;code&gt;@metered-ca/realtime&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
The managed signaling endpoint has a free tier for prototypes and hobby work (publishable-key auth, no credit card to start), and the SDK itself is MIT-licensed and free. Usage-based pricing applies beyond the free tier; check &lt;a href="https://www.metered.ca/" rel="noopener noreferrer"&gt;metered.ca&lt;/a&gt; for current limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there an open-source WebRTC signaling server?&lt;/strong&gt;&lt;br&gt;
Yes — many. Because a signaling server is just a message relay, open-source examples exist for nearly every stack (the &lt;code&gt;ws&lt;/code&gt;-based one above is ~40 lines; there are fuller Socket.IO, Node, Go, and Rust projects on GitHub). Note the distinction from this guide's managed option: the &lt;code&gt;@metered-ca/realtime&lt;/code&gt; &lt;em&gt;client SDK&lt;/em&gt; is open source (MIT), while the managed signaling &lt;em&gt;backend&lt;/em&gt; it connects to is operated by Metered, not self-hosted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I build a WebRTC signaling server in Node.js?&lt;/strong&gt;&lt;br&gt;
Stand up a WebSocket server (the &lt;a href="https://github.com/websockets/ws" rel="noopener noreferrer"&gt;&lt;code&gt;ws&lt;/code&gt;&lt;/a&gt; package is the standard choice) that relays each peer's SDP offer/answer and ICE candidates to the other peer(s) in a room — exactly the &lt;code&gt;signaling-server.js&lt;/code&gt; above. WebRTC doesn't mandate the transport, but WebSocket fits because signaling is bidirectional and latency-sensitive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use Socket.IO for WebRTC signaling?&lt;/strong&gt;&lt;br&gt;
Yes — Socket.IO is a popular choice because its rooms API maps cleanly onto call rooms. The mechanics are identical to the raw-&lt;code&gt;ws&lt;/code&gt; version here: relay &lt;code&gt;offer&lt;/code&gt; / &lt;code&gt;answer&lt;/code&gt; / &lt;code&gt;ice&lt;/code&gt; events between peers; Socket.IO just adds rooms, auto-reconnect of the &lt;em&gt;socket&lt;/em&gt;, and fallbacks on top. (It reconnects the WebSocket, but you still own WebRTC-level ICE restart and media re-attach.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I build a signaling server in Python / C# / PHP / Go?&lt;/strong&gt;&lt;br&gt;
Absolutely — the signaling server is language-agnostic because it only moves SDP and ICE JSON between peers. Python (&lt;code&gt;websockets&lt;/code&gt;/&lt;code&gt;aiohttp&lt;/code&gt;), C# (&lt;code&gt;SignalR&lt;/code&gt;/ASP.NET WebSockets), PHP (Ratchet), and Go (&lt;code&gt;gorilla/websocket&lt;/code&gt;) are all common. The &lt;em&gt;browser&lt;/em&gt; side is always JavaScript (&lt;code&gt;RTCPeerConnection&lt;/code&gt;), but the relay can be anything that speaks WebSocket.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there a public WebRTC signaling server I can point at?&lt;/strong&gt;&lt;br&gt;
Public/managed endpoints exist — that's exactly what managed signaling like &lt;code&gt;wss://rms.metered.ca/v1&lt;/code&gt; is (you authenticate with a key rather than hosting it). Avoid pointing production traffic at random unauthenticated public relays: signaling carries connection metadata and, without auth, anyone can join your rooms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between a signaling server and a TURN server?&lt;/strong&gt;&lt;br&gt;
Different jobs. The &lt;strong&gt;signaling server&lt;/strong&gt; relays the &lt;em&gt;handshake&lt;/em&gt; (SDP + ICE candidates) so peers can find each other; it never carries media. A &lt;strong&gt;TURN server&lt;/strong&gt; relays the &lt;em&gt;media itself&lt;/em&gt; when a direct peer-to-peer path is impossible (symmetric NAT, restrictive firewalls). You often need both: signaling to set up the call, TURN as a media fallback. Managed signaling typically also &lt;em&gt;delivers&lt;/em&gt; the TURN credentials to clients, which DIY signaling leaves to you.&lt;/p&gt;




&lt;h2&gt;
  
  
  Recipe (for skimmers)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Build (DIY):&lt;/strong&gt; a signaling server is a WebSocket relay. Run a Node &lt;code&gt;ws&lt;/code&gt; server that forwards each peer's SDP offer/answer + ICE candidates to the other peer in a room (~40 lines, above); the browser does the rest with raw &lt;code&gt;RTCPeerConnection&lt;/code&gt;. It never touches media. The catch: you then own rooms, reconnection, auth, TURN delivery, presence, and scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buy (managed):&lt;/strong&gt; skip the server. &lt;code&gt;import { MeteredPeer } from "https//esm.sh/@metered-ca/realtime@1.0.8"&lt;/code&gt;, &lt;code&gt;new MeteredPeer({ apiKey: "pk_live_…" })&lt;/code&gt;, &lt;code&gt;addStream(localStream)&lt;/code&gt;, &lt;code&gt;await peer.join("room")&lt;/code&gt; — both tabs run identical code, and &lt;code&gt;wss://rms.metered.ca/v1&lt;/code&gt; handles signaling, reconnection, presence, and TURN credential delivery. Zero backend for prototypes; swap &lt;code&gt;apiKey&lt;/code&gt; for &lt;code&gt;tokenProvider&lt;/code&gt; (JWT) in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fork:&lt;/strong&gt; build it to learn or to self-host; use managed to ship.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Last reviewed: 2026-06-04.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Verification: BUILD code (Node + &lt;code&gt;ws&lt;/code&gt; server + raw &lt;code&gt;RTCPeerConnection&lt;/code&gt; client) run end-to-end in two real Chromium tabs on 2026-06-04 — both peers reached &lt;code&gt;connectionState: "connected"&lt;/code&gt; and each tab's remote video received the other's stream (640×480 both ways); the server boots, relays to the other peer only (no echo), and rejects a third peer (close 1013). BUY code: every &lt;code&gt;@metered-ca/&lt;/code&gt; API verified against the live SDK docs (&lt;code&gt;metered.ca/docs/llms-realtime-messaging-sdk.txt&lt;/code&gt;, re-fetched 2026-06-03) — &lt;code&gt;new MeteredPeer({ apiKey })&lt;/code&gt;/&lt;code&gt;tokenProvider&lt;/code&gt;, &lt;code&gt;join&lt;/code&gt;, &lt;code&gt;addStream&lt;/code&gt;, &lt;code&gt;peer-joined { peer }&lt;/code&gt;, &lt;code&gt;remote.id&lt;/code&gt;, &lt;code&gt;state-change { from, to }&lt;/code&gt;, &lt;code&gt;stream-added { stream }&lt;/code&gt; (no &lt;code&gt;remote.streams&lt;/code&gt; array). CDN pin &lt;code&gt;@metered-ca/realtime@1.0.8&lt;/code&gt; resolves to a real ESM module on esm.sh (HTTP 200); 1.0.7 confirmed latest on npm (MIT, zero runtime deps, ~13 KB gzipped with WebRTC). Signaling endpoint &lt;code&gt;wss://rms.metered.ca/v1&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>webrtc</category>
      <category>javascript</category>
      <category>programming</category>
    </item>
    <item>
      <title>WebRTC Reconnect: Auto-Heal a Call | @metered-ca/realtime</title>
      <dc:creator>alakkadshaw</dc:creator>
      <pubDate>Thu, 04 Jun 2026 14:52:31 +0000</pubDate>
      <link>https://dev.to/alakkadshaw/webrtc-reconnect-auto-heal-a-call-metered-capeer-36hh</link>
      <guid>https://dev.to/alakkadshaw/webrtc-reconnect-auto-heal-a-call-metered-capeer-36hh</guid>
      <description>&lt;h1&gt;
  
  
  WebRTC Reconnect: Drop the Network, Watch a 1:1 Call Heal Itself
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;WebRTC reconnect, in one sentence:&lt;/strong&gt; raw WebRTC has &lt;em&gt;no&lt;/em&gt; built-in reconnection — a Wi-Fi blip or a Wi-Fi→cellular handoff leaves your &lt;code&gt;RTCPeerConnection&lt;/code&gt; stuck in &lt;code&gt;disconnected&lt;/code&gt;/&lt;code&gt;failed&lt;/code&gt; with no recovery — so this tutorial builds a runnable 1:1 video call with &lt;a href="https://www.npmjs.com/package/@metered-ca/realtime" rel="noopener noreferrer"&gt;&lt;code&gt;@metered-ca/realtime&lt;/code&gt;&lt;/a&gt;, then kills the network mid-call and watches the SDK auto-recover the same peer (same identity, fresh ICE/TURN underneath) with zero reconnect code on your side.&lt;/p&gt;

&lt;p&gt;That's the whole demo: &lt;strong&gt;drop the network, watch the call heal.&lt;/strong&gt; You'll read the exact state transitions as they happen — a remote peer going &lt;code&gt;reconnecting → connected&lt;/code&gt;, both &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; tiles re-attaching on their own — without writing a reconnect button, a manual ICE-restart loop, or a &lt;code&gt;peer.reconnect()&lt;/code&gt; call. The point of this tutorial is the thing you &lt;em&gt;don't&lt;/em&gt; write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Goal
&lt;/h2&gt;

&lt;p&gt;By the end you'll have a &lt;strong&gt;WebRTC reconnect&lt;/strong&gt; demo you can &lt;em&gt;prove&lt;/em&gt;: a live 1:1 call, an on-screen status log, and a repeatable way to drop the network and watch &lt;code&gt;@metered-ca/realtime&lt;/code&gt; rebuild the connection automatically — same remote peer, same identity, fresh ICE/TURN underneath — without you writing a single line of recovery logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node 18+&lt;/strong&gt; and &lt;strong&gt;npm&lt;/strong&gt; (only to serve one file — the SDK itself has zero runtime dependencies).&lt;/li&gt;
&lt;li&gt;A free &lt;strong&gt;publishable key&lt;/strong&gt; (&lt;code&gt;pk_live_…&lt;/code&gt;) from your Metered dashboard — sign up at &lt;a href="https://www.metered.ca/" rel="noopener noreferrer"&gt;metered.ca&lt;/a&gt;. This is the no-backend prototype path; nothing runs server-side.&lt;/li&gt;
&lt;li&gt;A modern browser: &lt;strong&gt;Chrome 90+ / Firefox 90+ / Safari 15+&lt;/strong&gt;. We'll use &lt;strong&gt;Chrome DevTools&lt;/strong&gt; to simulate the outage because its "Offline" toggle is the cleanest trigger.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;getUserMedia&lt;/code&gt; needs &lt;strong&gt;HTTPS or &lt;code&gt;localhost&lt;/code&gt;&lt;/strong&gt; — serve the file, don't open &lt;code&gt;file://&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why WebRTC connections drop (and why raw WebRTC won't recover)
&lt;/h2&gt;

&lt;p&gt;Three everyday things break a live call, and stock WebRTC handles none of them for you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A network change&lt;/strong&gt; — Wi-Fi→cellular handoff, leaving a tunnel, laptop sleep/wake. Your local IP and candidate set change out from under the connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A transient path loss&lt;/strong&gt; — a few seconds of packet loss flips &lt;code&gt;RTCPeerConnection.iceConnectionState&lt;/code&gt; to &lt;code&gt;disconnected&lt;/code&gt;, and if it doesn't recover, on to &lt;code&gt;failed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signaling loss&lt;/strong&gt; — the WebSocket carrying SDP/ICE drops, so even when the network returns there's no channel to renegotiate over.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Raw WebRTC gives you the &lt;em&gt;events&lt;/em&gt; (&lt;code&gt;connectionstatechange&lt;/code&gt;, &lt;code&gt;iceconnectionstatechange&lt;/code&gt;) but no &lt;em&gt;recovery&lt;/em&gt;: there is no built-in "rebuild this call." You'd have to detect &lt;code&gt;disconnected&lt;/code&gt;, decide whether it's transient or terminal, fire an ICE restart, renegotiate over a signaling channel you also had to keep alive — and then re-attach media. That hand-rolled ladder is exactly what &lt;code&gt;@metered-ca/realtime&lt;/code&gt; does for you, and what the rest of this page makes observable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model (read this before the code)
&lt;/h2&gt;

&lt;p&gt;There is exactly one idea to internalize, and it's the one the older peer-ID libraries get wrong:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A transient disconnect is not a terminal close.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When the network blips, your peer hasn't &lt;em&gt;left&lt;/em&gt; — it's briefly unreachable. &lt;code&gt;@metered-ca/realtime&lt;/code&gt; treats that as a recoverable event and heals it on three layers (all automatic, all part of the SDK's documented resilience model):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Signaling WebSocket&lt;/strong&gt; reconnects with jittered exponential backoff (~500 ms → 30 s), and it's &lt;strong&gt;close-code-aware&lt;/strong&gt; — a graceful server shutdown is retried differently from a terminal kick (e.g. an invalid/expired token or an admin disconnect is &lt;em&gt;not&lt;/em&gt; retried). Default ~100 attempts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-peer ICE restart&lt;/strong&gt; — the SDK runs an ICE-restart ladder (up to &lt;strong&gt;9 attempts over ~121 s&lt;/strong&gt;) to rebuild the media path. While this runs, that peer surfaces as &lt;code&gt;remote.state === "reconnecting"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Channel reconciliation&lt;/strong&gt; — on WebSocket reconnect, your &lt;strong&gt;&lt;code&gt;RemotePeer&lt;/code&gt; object reference is preserved&lt;/strong&gt; (same &lt;code&gt;===&lt;/code&gt; identity, same &lt;code&gt;remote.id&lt;/code&gt;, same metadata). The SDK silently &lt;strong&gt;swaps the underlying &lt;code&gt;RTCPeerConnection&lt;/code&gt;&lt;/strong&gt; for a fresh one with new TURN credentials, and your local streams auto-re-attach.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What survives a reconnect: peer references, IDs, metadata, and your local stream attachments. What does &lt;strong&gt;not&lt;/strong&gt; survive: the &lt;code&gt;RTCPeerConnection&lt;/code&gt; object identity (&lt;code&gt;remote.pc&lt;/code&gt;), any &lt;code&gt;RTCDataChannel&lt;/code&gt;, and the remote &lt;code&gt;MediaStream&lt;/code&gt; &lt;em&gt;object&lt;/em&gt; identity — though &lt;code&gt;stream.id&lt;/code&gt; stays stable, and on reconcile the SDK re-fires &lt;code&gt;stream-added&lt;/code&gt; with that same &lt;code&gt;stream.id&lt;/code&gt; so you just re-bind. Hold that last list — it's the whole pitfalls section.&lt;/p&gt;

&lt;p&gt;The contrast with a deliberate teardown is the design's core. &lt;code&gt;peer.close()&lt;/code&gt; is &lt;strong&gt;terminal&lt;/strong&gt;: it tears down on purpose and you do &lt;em&gt;not&lt;/em&gt; get auto-recovery (you'd construct a fresh &lt;code&gt;MeteredPeer&lt;/code&gt;). Everything else — Wi-Fi drops, tunnels, laptop sleep, a flaky LTE handoff — is treated as recoverable. That clean split between &lt;em&gt;recoverable blip&lt;/em&gt; and &lt;em&gt;intentional close&lt;/em&gt; is precisely what trips up older peer-ID libraries that collapse a transient ICE &lt;code&gt;disconnected&lt;/code&gt; into a terminal "destroyed".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh5l8o2hao1om9h3lp9w2.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fh5l8o2hao1om9h3lp9w2.png" alt="02-reconnect-state-machine" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The minimal runnable code
&lt;/h2&gt;

&lt;p&gt;One file. It's a complete 1:1 call plus a &lt;strong&gt;status pill&lt;/strong&gt; and a &lt;strong&gt;log&lt;/strong&gt;, so the reconnect is something you can &lt;em&gt;watch&lt;/em&gt;, not just trust. Save as &lt;code&gt;index.html&lt;/code&gt;, drop in your &lt;code&gt;pk_live_&lt;/code&gt; key, serve, open in two tabs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!doctype html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"utf-8"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;WebRTC reconnect - @metered-ca/realtime&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;video&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;320px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#111&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;system-ui&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nf"&gt;#log&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;13px&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.5&lt;/span&gt; &lt;span class="n"&gt;ui-monospace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;monospace&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#0b1020&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#d6e2ff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
             &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;160px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nc"&gt;.pill&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inline-block&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="m"&gt;10px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;999px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;600&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nc"&gt;.connected&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#dcfce7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#166534&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nc"&gt;.reconnecting&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#fef3c7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#92400e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nc"&gt;.closed&lt;/span&gt;       &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#fee2e2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#991b1b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;WebRTC reconnect demo&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"join"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Join call&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"pill"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;idle&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;video&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"local"&lt;/span&gt; &lt;span class="na"&gt;autoplay&lt;/span&gt; &lt;span class="na"&gt;playsinline&lt;/span&gt; &lt;span class="na"&gt;muted&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/video&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;video&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"remote"&lt;/span&gt; &lt;span class="na"&gt;autoplay&lt;/span&gt; &lt;span class="na"&gt;playsinline&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/video&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"log"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MeteredPeer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://esm.sh/@metered-ca/realtime@1.0.7&lt;/span&gt;&lt;span class="dl"&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;CHANNEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;room-42&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// both tabs join the SAME channel&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pk_live_REPLACE_ME&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;--&lt;/span&gt; &lt;span class="nx"&gt;your&lt;/span&gt; &lt;span class="nx"&gt;publishable&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;localVideo&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;local&lt;/span&gt;&lt;span class="dl"&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;remoteVideo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;remote&lt;/span&gt;&lt;span class="dl"&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;statusEl&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="dl"&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;logEl&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;log&lt;/span&gt;&lt;span class="dl"&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;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&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;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLocaleTimeString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nx"&gt;logEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;insertAdjacentHTML&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;afterbegin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;div&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/div&amp;gt;`&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;setStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&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;statusEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;statusEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pill &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// styles "connected"/"reconnecting"/"closed"&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;

      &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;join&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;onclick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &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="c1"&gt;// 1. Local camera + mic (HTTPS or localhost)&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;localStream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="nx"&gt;localVideo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcObject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStream&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;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;MeteredPeer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PK&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="c1"&gt;// 2. Top-level signaling health (the WebSocket layer).&lt;/span&gt;
        &lt;span class="c1"&gt;//    Local peer states: idle | joining | joined | reconnecting | leaving | closed&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;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;state-change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`peer: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="c1"&gt;// 3. Per-peer lifecycle - THIS is where reconnect shows up.&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;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;peer-joined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;remote&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="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`peer-joined: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

          &lt;span class="c1"&gt;// Remote peer states: idle | connecting | connected | reconnecting | closed&lt;/span&gt;
          &lt;span class="nx"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;state-change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&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="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`  remote &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;});&lt;/span&gt;

          &lt;span class="c1"&gt;// The SDK hands us the live stream here - and RE-FIRES this on reconcile&lt;/span&gt;
          &lt;span class="c1"&gt;// with the SAME stream.id but a NEW MediaStream object. So we just re-bind.&lt;/span&gt;
          &lt;span class="nx"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stream-added&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;remoteVideo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcObject&lt;/span&gt; &lt;span class="o"&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`stream-added (re)bound: &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="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;});&lt;/span&gt;
          &lt;span class="nx"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stream-removed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;remoteVideo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcObject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

          &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;peer-left&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;remote&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="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`peer-left: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="nx"&gt;remoteVideo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcObject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="c1"&gt;// 4. Publish our camera to the whole channel&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;addStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;camera&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="c1"&gt;// 5. Connect&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CHANNEL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`joined &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;CHANNEL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; as &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="nx"&gt;peerId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Notice there is &lt;strong&gt;no reconnect code&lt;/strong&gt;. Every line above is either UI or a &lt;em&gt;listener&lt;/em&gt;. Recovery is the SDK's job; your job is to re-bind the stream when it re-fires &lt;code&gt;stream-added&lt;/code&gt;, and to read state when it tells you where it is.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step-by-step annotations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1 - The call itself (steps 1, 4, 5).&lt;/strong&gt; &lt;code&gt;getUserMedia()&lt;/code&gt; gets your camera/mic; &lt;code&gt;peer.addStream(localStream, { role: "camera" })&lt;/code&gt; fans that stream out to &lt;em&gt;every&lt;/em&gt; peer in the channel (no per-target &lt;code&gt;call(remoteId)&lt;/code&gt; loop); &lt;code&gt;peer.join(CHANNEL)&lt;/code&gt; connects. Two tabs join the same &lt;code&gt;CHANNEL&lt;/code&gt;, discover each other, and the call is up. Everything else on the page is about &lt;em&gt;observing&lt;/em&gt; the recovery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2 - Top-level &lt;code&gt;state-change&lt;/code&gt; is the signaling pulse.&lt;/strong&gt; &lt;code&gt;peer.on("state-change", ({ from, to }) =&amp;gt; …)&lt;/code&gt; reports the health of your &lt;strong&gt;signaling WebSocket&lt;/strong&gt; — layer 1. Read the payload as &lt;code&gt;{ from, to }&lt;/code&gt; (the transition), not a single &lt;code&gt;state&lt;/code&gt;. The local peer moves through &lt;code&gt;joining → joined&lt;/code&gt;, and during an outage you'll see it dip to &lt;code&gt;reconnecting&lt;/code&gt; and climb back to &lt;code&gt;joined&lt;/code&gt;. This is the coarse signal: "is my control channel up?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3 - Per-peer &lt;code&gt;state-change&lt;/code&gt; is where reconnection lives.&lt;/strong&gt; This is the important one. Each remote peer has its &lt;strong&gt;own&lt;/strong&gt; &lt;code&gt;state-change&lt;/code&gt;, and its states are different from the top-level peer's: &lt;code&gt;idle | connecting | connected | reconnecting | closed&lt;/code&gt;. During a network blip a remote transitions to &lt;code&gt;reconnecting&lt;/code&gt; (the ICE-restart ladder is running) and then back to &lt;code&gt;connected&lt;/code&gt; (media path rebuilt). We mirror &lt;code&gt;to&lt;/code&gt; straight into the on-screen pill, so the recovery is visible. One idea per layer: the top-level event is about &lt;em&gt;your&lt;/em&gt; socket; the per-peer event is about &lt;em&gt;that peer's&lt;/em&gt; media path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4 - Re-bind on &lt;code&gt;stream-added&lt;/code&gt;, never cache the stream.&lt;/strong&gt; Here's the subtle, important bit. The remote stream arrives via &lt;code&gt;remote.on("stream-added", ({ stream }) =&amp;gt; …)&lt;/code&gt; — and on a reconnect the SDK &lt;strong&gt;re-fires &lt;code&gt;stream-added&lt;/code&gt;&lt;/strong&gt; with the &lt;em&gt;same&lt;/em&gt; &lt;code&gt;stream.id&lt;/code&gt; but a &lt;em&gt;new&lt;/em&gt; &lt;code&gt;MediaStream&lt;/code&gt; object. So you don't poll for a stream or hold a reference across the drop: you just point the &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; at whatever &lt;code&gt;stream&lt;/code&gt; the event hands you, every time it fires. That single handler covers both the first attach and every reconnect re-attach. (We never touch the underlying &lt;code&gt;RTCPeerConnection&lt;/code&gt; here — that's the footgun below.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5 - &lt;code&gt;peer-left&lt;/code&gt; vs. a blip.&lt;/strong&gt; A real &lt;code&gt;peer-left&lt;/code&gt; (payload: &lt;code&gt;{ peer }&lt;/code&gt;) means the other side intentionally &lt;code&gt;close()&lt;/code&gt;d or genuinely went away — clear the tile. A transient drop does &lt;strong&gt;not&lt;/strong&gt; fire &lt;code&gt;peer-left&lt;/code&gt;; it fires the per-peer &lt;code&gt;state-change&lt;/code&gt; to &lt;code&gt;reconnecting&lt;/code&gt;. Keeping these two paths distinct is the whole "transient ≠ terminal" idea in code: don't tear your UI down on a blip you're about to recover from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @metered-ca/realtime        &lt;span class="c"&gt;# zero runtime deps (the CDN import above is for copy-paste)&lt;/span&gt;
npx serve &lt;span class="nb"&gt;.&lt;/span&gt;                          &lt;span class="c"&gt;# serves on http://localhost:3000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Open &lt;strong&gt;&lt;a href="http://localhost:3000" rel="noopener noreferrer"&gt;http://localhost:3000&lt;/a&gt;&lt;/strong&gt; in &lt;strong&gt;Tab A&lt;/strong&gt;, click &lt;strong&gt;Join call&lt;/strong&gt;, accept the camera prompt.&lt;/li&gt;
&lt;li&gt;Open the same URL in &lt;strong&gt;Tab B&lt;/strong&gt;, click &lt;strong&gt;Join call&lt;/strong&gt;. You now have a 1:1 call; the status pill reads &lt;strong&gt;connected&lt;/strong&gt; and the log shows &lt;code&gt;peer-joined&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Now break it.&lt;/strong&gt; In &lt;strong&gt;Tab A&lt;/strong&gt;, open Chrome DevTools (&lt;code&gt;Cmd/Ctrl+Shift+I&lt;/code&gt;) → &lt;strong&gt;Network&lt;/strong&gt; tab → change the throttling dropdown from "No throttling" to &lt;strong&gt;Offline&lt;/strong&gt;. (No DevTools? Toggle your machine's Wi-Fi off for ~5 seconds, then on.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What you should see:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;The status pill flips to &lt;strong&gt;reconnecting&lt;/strong&gt; (amber) within a second or two.&lt;/li&gt;
&lt;li&gt;The log prints &lt;code&gt;remote … : connected -&amp;gt; reconnecting&lt;/code&gt;, and the top-level &lt;code&gt;peer:&lt;/code&gt; line shows the signaling socket dipping (&lt;code&gt;joined -&amp;gt; reconnecting&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;The video may freeze on its last frame — that's expected; the media path is being rebuilt.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restore the network&lt;/strong&gt; (set throttling back to "No throttling", or turn Wi-Fi back on). Within a few seconds:

&lt;ul&gt;
&lt;li&gt;The log prints &lt;code&gt;remote … : reconnecting -&amp;gt; connected&lt;/code&gt; and &lt;code&gt;stream-added (re)bound: …&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The pill returns to &lt;strong&gt;connected&lt;/strong&gt; (green) and both tiles resume live video.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You just watched all three resilience layers fire — socket backoff, ICE-restart ladder, channel reconciliation — without writing any of them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjy81p6g9blypmu70xrwy.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fjy81p6g9blypmu70xrwy.png" alt="03-result-reconnect-log" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A note on the prototype path:&lt;/strong&gt; with a &lt;code&gt;pk_live_&lt;/code&gt; key on &lt;code&gt;localhost&lt;/code&gt;, the call usually re-establishes on host/STUN candidates alone. Across real NATs the reconnect &lt;em&gt;depends on a relay&lt;/em&gt; — see TURN, below. The local demo proves the state machine; production needs the TURN piece behind it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  WebRTC ICE restart, auto reconnect, and the &lt;code&gt;disconnected&lt;/code&gt; state — how the three map
&lt;/h2&gt;

&lt;p&gt;If you searched for "webrtc ice restart" or "webrtc auto reconnect" or "webrtc connection failed", here's how those raw-WebRTC concepts line up with what the SDK is doing for you:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Raw WebRTC concept&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;What &lt;code&gt;@metered-ca/realtime&lt;/code&gt; does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iceConnectionState: "disconnected"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Transient path loss; &lt;em&gt;may&lt;/em&gt; self-heal&lt;/td&gt;
&lt;td&gt;Treated as recoverable; kicks off the per-peer ICE-restart ladder. Surfaces to you as &lt;code&gt;remote.state === "reconnecting"&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iceConnectionState: "failed"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ICE gave up on the current candidates&lt;/td&gt;
&lt;td&gt;The ICE-restart ladder gathers &lt;strong&gt;fresh&lt;/strong&gt; candidates with new TURN creds (up to 9 attempts / ~121 s) instead of you calling &lt;code&gt;restartIce()&lt;/code&gt; by hand.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;RTCPeerConnection.restartIce()&lt;/code&gt; / &lt;code&gt;createOffer({ iceRestart: true })&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The manual ICE-restart primitives&lt;/td&gt;
&lt;td&gt;Run for you on the ladder; you never call them.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signaling channel down&lt;/td&gt;
&lt;td&gt;No path to renegotiate over&lt;/td&gt;
&lt;td&gt;The signaling WebSocket reconnects itself (exp backoff ~500 ms→30 s, ~100 attempts) so renegotiation has a channel.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"auto reconnect" (the pattern)&lt;/td&gt;
&lt;td&gt;Detect → restart → renegotiate → re-attach media&lt;/td&gt;
&lt;td&gt;The whole pattern, automatic. Your only job: re-bind on &lt;code&gt;stream-added&lt;/code&gt;, observe &lt;code&gt;state-change&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The takeaway: &lt;strong&gt;"WebRTC auto reconnect" isn't one switch — it's that whole ladder.&lt;/strong&gt; Doing it by hand means wiring all five rows yourself and racing your own retries against the browser's. Here it's the SDK's job.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part: reconnecting to the &lt;em&gt;same&lt;/em&gt; peer (identity preservation)
&lt;/h2&gt;

&lt;p&gt;Restarting ICE is the easy half. The half that bites people is &lt;strong&gt;identity&lt;/strong&gt;: after the network heals, is this the &lt;em&gt;same&lt;/em&gt; call, or did you just create a brand-new peer with a brand-new ID and lose all the per-peer state you'd built up (who they are, their metadata, your UI tile keyed to them)?&lt;/p&gt;

&lt;p&gt;This is the sharp edge where older peer-ID libraries struggle — many key everything off a connection-scoped ID, so when the transport is rebuilt you effectively get a &lt;em&gt;new&lt;/em&gt; peer and have to reconcile it yourself. &lt;code&gt;@metered-ca/realtime&lt;/code&gt; is built the other way around:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;&lt;code&gt;RemotePeer&lt;/code&gt; object reference is preserved&lt;/strong&gt; across the drop — same &lt;code&gt;===&lt;/code&gt; identity, same &lt;code&gt;remote.id&lt;/code&gt;, same &lt;code&gt;remote.metadata&lt;/code&gt;. The handler you registered in &lt;code&gt;peer-joined&lt;/code&gt; keeps working; you don't re-wire anything.&lt;/li&gt;
&lt;li&gt;Only the &lt;strong&gt;transport&lt;/strong&gt; underneath is swapped — a fresh &lt;code&gt;RTCPeerConnection&lt;/code&gt; with new TURN credentials.&lt;/li&gt;
&lt;li&gt;So your tile, your &lt;code&gt;remote.on("state-change")&lt;/code&gt; listener, and any per-peer state stay valid. You react to &lt;code&gt;reconnecting&lt;/code&gt;/&lt;code&gt;connected&lt;/code&gt;; you don't rebuild identity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the wedge of this whole tutorial: &lt;strong&gt;the connection is disposable; the peer is not.&lt;/strong&gt; Identity survives the drop, the media path is rebuilt under it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;#1 footgun - never cache &lt;code&gt;remote.pc&lt;/code&gt; across a reconnect.&lt;/strong&gt; This is the single mistake that turns "it just works" into "it works until the first Wi-Fi blip." The remote peer object is &lt;strong&gt;stable&lt;/strong&gt; across a reconnect (same &lt;code&gt;===&lt;/code&gt; identity, same &lt;code&gt;remote.id&lt;/code&gt;, same metadata), but the SDK &lt;strong&gt;swaps the underlying &lt;code&gt;RTCPeerConnection&lt;/code&gt;&lt;/strong&gt; for a fresh one with new ICE/TURN. So if you reach for the low-level connection via the documented &lt;code&gt;remote.pc&lt;/code&gt; escape hatch (to read stats, add a custom track, open a data channel), a handle you grabbed &lt;em&gt;before&lt;/em&gt; the drop points at a &lt;strong&gt;dead PC&lt;/strong&gt; afterward. Re-read &lt;code&gt;remote.pc&lt;/code&gt; only after that peer reports &lt;code&gt;state-change → connected&lt;/code&gt;. In this tutorial we never touch &lt;code&gt;pc&lt;/code&gt; — the SDK re-attaches media for us — which is exactly why this demo survives a reconnect for free.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgo8lg5eq5nymoo48kjal.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fgo8lg5eq5nymoo48kjal.png" alt="04-remote-pc-swap" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The remote &lt;code&gt;MediaStream&lt;/code&gt; &lt;em&gt;object&lt;/em&gt; isn't stable either — &lt;code&gt;stream.id&lt;/code&gt; is.&lt;/strong&gt; Same root cause. On reconcile the SDK re-fires &lt;code&gt;stream-added&lt;/code&gt; with a &lt;strong&gt;new&lt;/strong&gt; &lt;code&gt;MediaStream&lt;/code&gt; object but the &lt;strong&gt;same&lt;/strong&gt; &lt;code&gt;stream.id&lt;/code&gt;. If you keyed UI off the stream &lt;em&gt;object&lt;/em&gt;, it'll look "lost." Bind directly from the event payload every time it fires (as the demo does), or key off &lt;code&gt;stream.id&lt;/code&gt;. And note: &lt;code&gt;stream-removed&lt;/code&gt; is &lt;strong&gt;suppressed during reconcile&lt;/strong&gt; — so a brief drop won't trick you into tearing the tile down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't write a reconnect loop.&lt;/strong&gt; Coming from older peer-ID libraries, the instinct is to listen for a disconnect and call something like &lt;code&gt;peer.reconnect()&lt;/code&gt;. There is no such call here, and you don't want one — manual reconnect logic racing the SDK's own backoff is how you get the "socket opens but no events fire" class of bug. Recovery is automatic; you only &lt;em&gt;observe&lt;/em&gt; it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;close()&lt;/code&gt; is terminal - it is not "disconnect".&lt;/strong&gt; &lt;code&gt;peer.close(reason?)&lt;/code&gt; permanently tears the instance down; you can't &lt;code&gt;join()&lt;/code&gt; on it again, and it will &lt;strong&gt;not&lt;/strong&gt; auto-recover. It's for intentional teardown (user hangs up, component unmounts), not for handling a blip. If you call &lt;code&gt;close()&lt;/code&gt; expecting it to reconnect later, nothing will — construct a fresh &lt;code&gt;MeteredPeer&lt;/code&gt; for a new session. Conflating user-initiated disconnect with accidental drops is the classic peer-ID-library footgun: an intentional teardown and a transient ICE &lt;code&gt;disconnected&lt;/code&gt; are &lt;em&gt;not&lt;/em&gt; the same event, and treating them the same is what breaks recovery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconnect across real NATs needs TURN.&lt;/strong&gt; On &lt;code&gt;localhost&lt;/code&gt; the recovery looks free because host candidates always work. Across symmetric NATs and corporate firewalls, rebuilding the media path &lt;em&gt;requires a relay&lt;/em&gt; — without TURN, the ICE-restart ladder has nothing to restart onto. See Next steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;getUserMedia&lt;/code&gt; still needs HTTPS or &lt;code&gt;localhost&lt;/code&gt;.&lt;/strong&gt; Serve the file; never open &lt;code&gt;file://&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do I reconnect a WebRTC call after a network change (Wi-Fi → cellular)?&lt;/strong&gt;&lt;br&gt;
You don't do it by hand. A network change flips ICE to &lt;code&gt;disconnected&lt;/code&gt;/&lt;code&gt;failed&lt;/code&gt;; &lt;code&gt;@metered-ca/realtime&lt;/code&gt; treats that as recoverable and runs the ICE-restart ladder (fresh candidates + TURN creds, up to 9 attempts / ~121 s) while the signaling WebSocket reconnects underneath. You react to the per-peer &lt;code&gt;state-change&lt;/code&gt; (&lt;code&gt;reconnecting → connected&lt;/code&gt;) and re-bind the stream when &lt;code&gt;stream-added&lt;/code&gt; re-fires.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the reconnect give me the &lt;em&gt;same&lt;/em&gt; peer, or a new one?&lt;/strong&gt;&lt;br&gt;
The same one. The &lt;code&gt;RemotePeer&lt;/code&gt; object reference, &lt;code&gt;remote.id&lt;/code&gt;, and &lt;code&gt;remote.metadata&lt;/code&gt; are all preserved across the drop — only the underlying &lt;code&gt;RTCPeerConnection&lt;/code&gt; is swapped. That's the identity-preservation guarantee: your per-peer handlers and UI keyed to that peer stay valid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between ICE &lt;code&gt;disconnected&lt;/code&gt; and &lt;code&gt;failed&lt;/code&gt; here?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;disconnected&lt;/code&gt; is a transient path loss that may self-heal; &lt;code&gt;failed&lt;/code&gt; means ICE gave up on the current candidates. The SDK doesn't make you branch on them — both feed the same ICE-restart ladder, which gathers fresh candidates rather than waiting for the dead path to come back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I just cache the &lt;code&gt;RTCPeerConnection&lt;/code&gt; and reuse it after a reconnect?&lt;/strong&gt;&lt;br&gt;
No — that's the #1 footgun. &lt;code&gt;remote.pc&lt;/code&gt; is a &lt;em&gt;different&lt;/em&gt; object after a reconcile. Re-read it only after the peer reports &lt;code&gt;state-change → connected&lt;/code&gt;; never hold a reference across a drop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How is this different from older peer-ID libraries' reconnect (e.g. a manual &lt;code&gt;reconnect()&lt;/code&gt; call)?&lt;/strong&gt;&lt;br&gt;
Categorically: older peer-ID libraries tend to expose a manual reconnect call and key state off a connection-scoped ID, so a transient &lt;code&gt;disconnected&lt;/code&gt; can collapse into a terminal close and a rebuilt transport looks like a &lt;em&gt;new&lt;/em&gt; peer. Here, recovery is automatic and the peer's identity is preserved across the rebuilt transport — you observe state, you don't drive reconnection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my reconnect work on localhost but fail for real users?&lt;/strong&gt;&lt;br&gt;
Because &lt;code&gt;localhost&lt;/code&gt; recovers on host candidates, but real users behind symmetric NATs / firewalls need a &lt;strong&gt;relay&lt;/strong&gt;. Without TURN the ICE-restart ladder has nothing to restart onto. Add TURN (next section) before you ship.&lt;/p&gt;
&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Add TURN, or reconnects fail in the real world.&lt;/strong&gt; This is not optional once you leave &lt;code&gt;localhost&lt;/code&gt;. Metered's &lt;a href="https://www.metered.ca/tools/openrelay/" rel="noopener noreferrer"&gt;Open Relay Project&lt;/a&gt; provides &lt;strong&gt;20 GB/month of free TURN&lt;/strong&gt; with zero setup — the relay the ICE-restart ladder needs to rebuild a media path behind a firewall. It's the single most common reason a demo that "reconnects fine on my machine" fails for real users on mobile data or office Wi-Fi.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deliver TURN credentials in a JWT.&lt;/strong&gt; When you move off &lt;code&gt;pk_live_&lt;/code&gt; for production, switch to the &lt;code&gt;tokenProvider&lt;/code&gt; (JWT) path. The SDK calls your provider on first connect &lt;strong&gt;and on every reconnect&lt;/strong&gt;, so you can embed fresh &lt;code&gt;iceServers&lt;/code&gt;/TURN credentials in the token's &lt;code&gt;metadata&lt;/code&gt;; the client reads them from the welcome message and each rebuilt &lt;code&gt;RTCPeerConnection&lt;/code&gt; gets working relay creds automatically. This is what makes reconnection robust in production — see the &lt;a href="https://www.metered.ca/docs/realtime-messaging/sdk-javascript/getting-started/" rel="noopener noreferrer"&gt;Realtime Messaging getting-started guide&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconnect a whole room, not one peer.&lt;/strong&gt; This demo pins a single remote &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;. For a real room, attach the same per-peer &lt;code&gt;state-change&lt;/code&gt; / &lt;code&gt;stream-added&lt;/code&gt; handlers to &lt;em&gt;every&lt;/em&gt; peer inside &lt;code&gt;peer-joined&lt;/code&gt;, and render a tile each — each peer recovers independently, on its own ICE-restart ladder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read stats safely.&lt;/strong&gt; Want a "reconnecting…" overlay driven by real ICE state, or bandwidth numbers? Reach for &lt;code&gt;remote.pc&lt;/code&gt; to call &lt;code&gt;getStats()&lt;/code&gt; — but obey the footgun: grab it fresh on &lt;code&gt;connected&lt;/code&gt;, never hold it across a drop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start from the call instead.&lt;/strong&gt; If you want the 1:1 call built up from scratch (camera, channel, fan-out) before adding resilience, the &lt;strong&gt;&lt;a href="https://dev.to/aprogrammer22/webrtc-video-call-tutorial-11-video-chat-in-js-with-metered-peer-4m1e"&gt;companion video-call tutorial&lt;/a&gt;&lt;/strong&gt; walks the same &lt;code&gt;@metered-ca/realtime&lt;/code&gt; call line by line.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Recipe (for skimmers)
&lt;/h2&gt;

&lt;p&gt;WebRTC reconnect in &lt;code&gt;@metered-ca/realtime&lt;/code&gt; is &lt;em&gt;listeners, not logic&lt;/em&gt;:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MeteredPeer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@metered-ca/realtime&lt;/span&gt;&lt;span class="dl"&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;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;MeteredPeer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pk_live_…&lt;/span&gt;&lt;span class="dl"&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;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;peer-joined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;remote&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="c1"&gt;// Remote states: idle | connecting | connected | reconnecting | closed&lt;/span&gt;
  &lt;span class="nx"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;state-change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&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="c1"&gt;// "reconnecting" during a blip, "connected" when healed&lt;/span&gt;
    &lt;span class="nf"&gt;updatePill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// Re-fires on reconcile with a NEW MediaStream (same stream.id) - just re-bind.&lt;/span&gt;
  &lt;span class="nx"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stream-added&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;remoteVideo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcObject&lt;/span&gt; &lt;span class="o"&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="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;addStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// fans out to the channel&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;room-42&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Transient ≠ terminal.&lt;/strong&gt; A network drop fires the per-peer &lt;code&gt;state-change&lt;/code&gt; (&lt;code&gt;{ from, to }&lt;/code&gt;: &lt;code&gt;reconnecting → connected&lt;/code&gt;); only &lt;code&gt;close()&lt;/code&gt; is terminal. Don't write a reconnect loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-bind on &lt;code&gt;stream-added&lt;/code&gt;, never cache.&lt;/strong&gt; The remote peer object and &lt;code&gt;remote.id&lt;/code&gt; are stable, but its &lt;code&gt;RTCPeerConnection&lt;/code&gt; (&lt;code&gt;remote.pc&lt;/code&gt;) and &lt;code&gt;MediaStream&lt;/code&gt; &lt;em&gt;object&lt;/em&gt; are swapped on reconnect — re-bind from the re-fired &lt;code&gt;stream-added&lt;/code&gt; (same &lt;code&gt;stream.id&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TURN is the production dependency.&lt;/strong&gt; Across real NATs the ICE-restart ladder needs a relay; add &lt;a href="https://www.metered.ca/tools/openrelay/" rel="noopener noreferrer"&gt;Open Relay&lt;/a&gt; (20 GB/mo free) before you ship.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Last reviewed: 2026-06-03.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;_Verified against &lt;code&gt;@metered-ca/realtime@1.0.7&lt;/code&gt; (latest on npm; resolves on esm.sh) and the live Metered docs (&lt;code&gt;llms-realtime-messaging.txt&lt;/code&gt;, &lt;code&gt;llms-realtime-messaging-sdk.txt&lt;/code&gt;, re-fetched 2026-06-03): &lt;code&gt;state-change&lt;/code&gt; payload is &lt;code&gt;{ from, to }&lt;/code&gt;; RemotePeer has no &lt;code&gt;.streams&lt;/code&gt; array (streams arrive via the &lt;code&gt;stream-added&lt;/code&gt; event, which re-fires on reconcile with a new &lt;code&gt;MediaStream&lt;/code&gt; but the same &lt;code&gt;stream.id&lt;/code&gt;); top-level peer states are &lt;code&gt;idle | joining | joined | reconnecting | leaving | closed&lt;/code&gt; and remote-peer states are &lt;code&gt;idle | connecting | connected | reconnecting | closed&lt;/code&gt;; &lt;code&gt;peer-joined&lt;/code&gt;/&lt;code&gt;peer-left&lt;/code&gt; carry &lt;code&gt;{ peer }&lt;/code&gt;; bundle ~13 KB gzipped (WebRTC included); free TURN = 20 GB/month via Open Relay. Sources: &lt;a href="https://www.metered.ca/docs/llms-realtime-messaging.txt" rel="noopener noreferrer"&gt;https://www.metered.ca/docs/llms-realtime-messaging.txt&lt;/a&gt; · &lt;a href="https://www.metered.ca/docs/llms-realtime-messaging-sdk.txt" rel="noopener noreferrer"&gt;https://www.metered.ca/docs/llms-realtime-messaging-sdk.txt&lt;/a&gt; · &lt;a href="https://www.metered.ca/tools/openrelay/" rel="noopener noreferrer"&gt;https://www.metered.ca/tools/openrelay/&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/@metered-ca/realtime" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/@metered-ca/realtime&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to Embed ChatGPT in Your Website: 5 Methods Compared [2026 Guide]</title>
      <dc:creator>alakkadshaw</dc:creator>
      <pubDate>Sat, 04 Apr 2026 21:35:44 +0000</pubDate>
      <link>https://dev.to/alakkadshaw/how-to-embed-chatgpt-in-your-website-5-methods-compared-2026-guide-5hk8</link>
      <guid>https://dev.to/alakkadshaw/how-to-embed-chatgpt-in-your-website-5-methods-compared-2026-guide-5hk8</guid>
      <description>&lt;p&gt;You want ChatGPT on your website. Maybe for customer support. Maybe to answer FAQs automatically. Or maybe you're running live events and need AI to handle the flood of questions pouring into your chat room. Learning how to embed ChatGPT in your website is simpler than you think - but there's more to consider than most guides tell you.&lt;/p&gt;

&lt;p&gt;Here's the thing: most guides only cover half the picture.&lt;/p&gt;

&lt;p&gt;They show you how to add a basic AI chatbot widget. But what happens when 5,000 people hit your site during a product launch? What about moderating AI responses before your chatbot tells a customer something embarrassingly wrong? And what if you need AI assistance in a group chat, not just a 1-to-1 support conversation?&lt;/p&gt;

&lt;p&gt;To embed ChatGPT in your website, you have two main approaches: use a no-code platform like Chatbase or Elfsight that gives you embed code in minutes, or build a custom integration using the OpenAI API. No-code solutions cost $0-50/month and take 5-15 minutes. API integration requires coding skills but offers full customization at $2.50-$10 per million tokens.&lt;/p&gt;

&lt;p&gt;But there's a third option nobody talks about: integrating ChatGPT into your existing chat infrastructure for group conversations, events, and scalable deployments.&lt;/p&gt;

&lt;p&gt;I've helped dozens of customers set up ChatGPT integrations through our webhook API at DeadSimpleChat. In this guide, I'll walk you through all five methods, show you when to use each, and share the scaling and moderation strategies that most articles skip entirely.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;TL;DR: You can embed ChatGPT in three main ways. Use a no-code platform if you want a simple 1-to-1 chatbot fast, usually in 5 to 15 minutes and for about $0 to $50 per month. Use the OpenAI API if you want more flexibility and direct control, which typically takes 1 to 4 hours to set up and uses pay-per-token pricing. Use webhook integration with your existing chat system if you need AI in group chats, live events, or large-scale apps, since this approach is built to support high-volume usage and more complex conversation flows.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Quick Comparison: 5 Ways to Embed ChatGPT
&lt;/h2&gt;

&lt;p&gt;Before diving into each method, here's how they stack up.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdlvxgk7b420yoh5cjm5d.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fdlvxgk7b420yoh5cjm5d.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;th&gt;Setup Time&lt;/th&gt;
&lt;th&gt;Monthly Cost&lt;/th&gt;
&lt;th&gt;Skill Level&lt;/th&gt;
&lt;th&gt;Recommendation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;No-code platforms&lt;/strong&gt; (Chatbase, Elfsight)&lt;/td&gt;
&lt;td&gt;Simple 1-to-1 chatbots&lt;/td&gt;
&lt;td&gt;5-15 minutes&lt;/td&gt;
&lt;td&gt;$0-150&lt;/td&gt;
&lt;td&gt;Beginner&lt;/td&gt;
&lt;td&gt;Best for quick MVPs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;WordPress plugins&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;WordPress sites&lt;/td&gt;
&lt;td&gt;10-20 minutes&lt;/td&gt;
&lt;td&gt;Free-$30&lt;/td&gt;
&lt;td&gt;Beginner&lt;/td&gt;
&lt;td&gt;Best for WP users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OpenAI API direct&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Custom experiences&lt;/td&gt;
&lt;td&gt;1-4 hours&lt;/td&gt;
&lt;td&gt;Pay-per-token&lt;/td&gt;
&lt;td&gt;Developer&lt;/td&gt;
&lt;td&gt;Best for control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Chat platform + AI&lt;/strong&gt; (webhooks)&lt;/td&gt;
&lt;td&gt;Group chat, events, scale&lt;/td&gt;
&lt;td&gt;30 min-2 hours&lt;/td&gt;
&lt;td&gt;Platform + API&lt;/td&gt;
&lt;td&gt;Intermediate&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Best for scale&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Custom development&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Enterprise, unique needs&lt;/td&gt;
&lt;td&gt;Days to weeks&lt;/td&gt;
&lt;td&gt;$$$&lt;/td&gt;
&lt;td&gt;Advanced&lt;/td&gt;
&lt;td&gt;Best for unique needs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Choose based on your use case: no-code for quick chatbots, API for custom builds, webhooks for scale and group chat.&lt;/p&gt;

&lt;p&gt;Let me break down each method.&lt;/p&gt;




&lt;h2&gt;
  
  
  Method 1: No-Code Platforms (Fastest Setup)
&lt;/h2&gt;

&lt;p&gt;No-code platforms are the fastest way to get ChatGPT on your website. You don't write any code. Just configure, copy, and paste.&lt;/p&gt;

&lt;h3&gt;
  
  
  How It Works
&lt;/h3&gt;

&lt;p&gt;These platforms give you a visual interface to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Train your chatbot on your website content, PDFs, or documents&lt;/li&gt;
&lt;li&gt;Customize the appearance (colors, position, avatar)&lt;/li&gt;
&lt;li&gt;Get an embed code to paste into your HTML&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole process takes 5-15 minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step-by-Step: Adding ChatGPT with Chatbase
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sign up&lt;/strong&gt; at chatbase.co (free tier available)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add your data sources&lt;/strong&gt; - paste your website URL, upload PDFs, or add text directly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait for training&lt;/strong&gt; - Chatbase crawls and indexes your content (usually under 5 minutes)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customize appearance&lt;/strong&gt; - choose colors, set the chat bubble position, add your logo&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy the embed code&lt;/strong&gt; and paste it before the &lt;code&gt;&amp;lt;/body&amp;gt;&lt;/code&gt; tag on your website&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcz7ha32dyfsqhhpvfkz4.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fcz7ha32dyfsqhhpvfkz4.png" alt=" " width="800" height="476"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Top No-Code Platforms Compared
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Free Tier&lt;/th&gt;
&lt;th&gt;Training Method&lt;/th&gt;
&lt;th&gt;Unique Feature&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Chatbase&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;100 messages/month&lt;/td&gt;
&lt;td&gt;URL, PDF, text&lt;/td&gt;
&lt;td&gt;Fast training, simple UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Elfsight&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;Widget config&lt;/td&gt;
&lt;td&gt;1-minute setup claim&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Denser.ai&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;URL, docs&lt;/td&gt;
&lt;td&gt;RAG technology (reduces hallucinations)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CustomGPT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Trial&lt;/td&gt;
&lt;td&gt;Knowledge base&lt;/td&gt;
&lt;td&gt;Live chat framing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FwdSlash&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;50 messages/month&lt;/td&gt;
&lt;td&gt;Behavior-driven&lt;/td&gt;
&lt;td&gt;Multi-channel (WhatsApp, Slack)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Pros and Cons
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Setup in minutes with zero coding&lt;/li&gt;
&lt;li&gt;Train on your specific business content&lt;/li&gt;
&lt;li&gt;Affordable pricing for small businesses&lt;/li&gt;
&lt;li&gt;Most include free tiers for testing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Limited customization compared to API&lt;/li&gt;
&lt;li&gt;Vendor lock-in (hard to migrate later)&lt;/li&gt;
&lt;li&gt;Only handles 1-to-1 conversations&lt;/li&gt;
&lt;li&gt;Can't scale to large concurrent audiences&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Small businesses wanting quick customer support chatbots without developer resources.&lt;/p&gt;




&lt;h2&gt;
  
  
  Method 2: WordPress Plugins
&lt;/h2&gt;

&lt;p&gt;If you're on WordPress, dedicated plugins make ChatGPT integration even simpler.&lt;/p&gt;

&lt;h3&gt;
  
  
  Recommended Plugins
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;AI Engine&lt;/strong&gt; (Free + Premium)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Direct OpenAI API integration&lt;/li&gt;
&lt;li&gt;Multiple chatbot styles&lt;/li&gt;
&lt;li&gt;Content generation features&lt;/li&gt;
&lt;li&gt;100,000+ active installations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;WoowBot&lt;/strong&gt; (For WooCommerce)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Product-aware responses&lt;/li&gt;
&lt;li&gt;Order status inquiries&lt;/li&gt;
&lt;li&gt;Shopping assistance&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Setup with AI Engine
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Install AI Engine from the WordPress plugin repository&lt;/li&gt;
&lt;li&gt;Go to Settings &amp;gt; AI Engine&lt;/li&gt;
&lt;li&gt;Enter your OpenAI API key (get one at platform.openai.com)&lt;/li&gt;
&lt;li&gt;Configure chatbot appearance and behavior&lt;/li&gt;
&lt;li&gt;Add the chatbot using a shortcode or widget
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Add chatbot via shortcode&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mwai_chatbot&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;// Or with custom settings&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mwai_chatbot&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"gpt-4o"&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"0.7"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxce4c3z0y5unxngx37id.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fxce4c3z0y5unxngx37id.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Method 3: OpenAI API Direct Integration (Maximum Control)
&lt;/h2&gt;

&lt;p&gt;For developers who need full control, direct API integration is the way to go. You manage everything: the UI, the backend, the conversation flow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;OpenAI API key (sign up at platform.openai.com)&lt;/li&gt;
&lt;li&gt;Backend server (Node.js, Python, or any language)&lt;/li&gt;
&lt;li&gt;Basic understanding of REST APIs&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Architecture Overview
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwpg45xlm8kpr9kubg1fg.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fwpg45xlm8kpr9kubg1fg.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; Never expose your API key in frontend code. Always route requests through your backend.&lt;/p&gt;

&lt;h3&gt;
  
  
  Node.js Implementation
&lt;/h3&gt;

&lt;p&gt;Here's a basic Express.js backend:&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="c1"&gt;// server.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&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;OpenAI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai&lt;/span&gt;&lt;span class="dl"&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;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;openai&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;OpenAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENAI_API_KEY&lt;/span&gt; &lt;span class="c1"&gt;// Store in environment variable&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Conversation history (in production, use a database)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;conversations&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;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/chat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Get or create conversation history&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;conversations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;conversations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You are a helpful assistant for [Your Company]. Answer questions about our products and services.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&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;history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;conversations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;try&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;completion&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;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Cost-effective option&lt;/span&gt;
      &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&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;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;completion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OpenAI error:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Failed to get response&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Cost Breakdown
&lt;/h3&gt;

&lt;p&gt;OpenAI charges per token (roughly 4 characters = 1 token). Here's what to expect:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Input (per 1M tokens)&lt;/th&gt;
&lt;th&gt;Output (per 1M tokens)&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPT-4o mini&lt;/td&gt;
&lt;td&gt;$0.15&lt;/td&gt;
&lt;td&gt;$0.60&lt;/td&gt;
&lt;td&gt;Cost-effective production&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-4o&lt;/td&gt;
&lt;td&gt;$2.50&lt;/td&gt;
&lt;td&gt;$10.00&lt;/td&gt;
&lt;td&gt;Complex reasoning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-4&lt;/td&gt;
&lt;td&gt;$30.00&lt;/td&gt;
&lt;td&gt;$60.00&lt;/td&gt;
&lt;td&gt;Legacy, avoid for new projects&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Example calculation:&lt;/strong&gt; A website with 1,000 daily conversations averaging 500 tokens each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Daily tokens: ~500,000&lt;/li&gt;
&lt;li&gt;Monthly tokens: ~15 million&lt;/li&gt;
&lt;li&gt;Monthly cost with GPT-4o mini: ~$11&lt;/li&gt;
&lt;li&gt;Monthly cost with GPT-4o: ~$187&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9jvkj49k197y030txook.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2F9jvkj49k197y030txook.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Security Best Practices
&lt;/h3&gt;

&lt;p&gt;According to &lt;a href="https://platform.openai.com/docs/" rel="noopener noreferrer"&gt;OpenAI's documentation&lt;/a&gt;, you should:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never expose API keys in client-side code&lt;/strong&gt; - route through your backend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use environment variables&lt;/strong&gt; - never hardcode keys&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implement rate limiting&lt;/strong&gt; - prevent abuse and control costs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set spending limits&lt;/strong&gt; - OpenAI dashboard lets you cap monthly spend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate and sanitize inputs&lt;/strong&gt; - prevent prompt injection attacks&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5cepzkjuo5lhu8e34e8q.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2F5cepzkjuo5lhu8e34e8q.png" alt=" " width="800" height="354"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 4: Chat Platform + AI Integration (The Scalable Approach)
&lt;/h2&gt;

&lt;p&gt;Here's what most guides miss: what if you need ChatGPT to work in a group chat? Or during a live event with thousands of concurrent users? Or as part of an existing chat system?&lt;/p&gt;

&lt;p&gt;This is where webhook-based integration shines.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Matters
&lt;/h3&gt;

&lt;p&gt;Standard AI chatbots handle 1-to-1 conversations. But real-world use cases often need more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live events&lt;/strong&gt;: AI answering questions in a chat room with 5,000 viewers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Communities&lt;/strong&gt;: AI assistant that responds when mentioned in group discussions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support queues&lt;/strong&gt;: AI handling initial triage before human handoff&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid chat&lt;/strong&gt;: Human agents assisted by AI suggestions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We've helped event organizers integrate ChatGPT into chat rooms handling 50,000+ concurrent users. The key is using webhooks to connect your chat platform to the OpenAI API.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Webhook Integration Works
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;User sends message in chat room&lt;/li&gt;
&lt;li&gt;Chat platform fires webhook to your server&lt;/li&gt;
&lt;li&gt;Your server calls OpenAI API with the message and context&lt;/li&gt;
&lt;li&gt;OpenAI returns response&lt;/li&gt;
&lt;li&gt;Your server posts AI response back to chat room via API&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmc7myx31pfrlrhioidv0.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fmc7myx31pfrlrhioidv0.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  DeadSimpleChat Webhook Example
&lt;/h3&gt;

&lt;p&gt;Here's how to set up AI integration with &lt;a href="https://deadsimplechat.com/features" rel="noopener noreferrer"&gt;DeadSimpleChat's webhook system&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;First, configure your webhook in the DeadSimpleChat dashboard:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh2qwmej9jtlh3kfoho0b.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fh2qwmej9jtlh3kfoho0b.png" alt=" " width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then, handle incoming webhooks and respond with AI:&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="c1"&gt;// Webhook handler for DeadSimpleChat + ChatGPT&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&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;OpenAI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai&lt;/span&gt;&lt;span class="dl"&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;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;openai&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;OpenAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPENAI_API_KEY&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;DSC_API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DEADSIMPLECHAT_API_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// AI trigger: respond when users mention @AI or ask questions&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AI_TRIGGER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/@ai|@assistant|&lt;/span&gt;&lt;span class="se"&gt;\?&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/chat-webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Only process new messages&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message.created&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userName&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Check if message should trigger AI&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;AI_TRIGGER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Get AI response&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;completion&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;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You are a helpful assistant in a group chat. Keep responses concise (under 100 words). Be friendly and helpful.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; asked: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&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;aiResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;completion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Post AI response back to chat room via DeadSimpleChat API&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://api.deadsimplechat.com/rooms/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/messages`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;DSC_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;aiResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;userName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AI Assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AI integration error:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  When to Use Webhook Integration
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use Case&lt;/th&gt;
&lt;th&gt;Why Webhooks Work&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Live events&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Handle thousands of concurrent AI requests across multiple chat rooms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Community forums&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;AI responds to mentions without being the primary interface&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hybrid support&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;AI handles first response, escalates to humans when needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Moderated AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Filter AI responses through moderation before posting&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Method 5: Custom Enterprise Development
&lt;/h2&gt;

&lt;p&gt;For unique requirements, enterprise teams often build fully custom solutions. This involves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Custom frontend chat interfaces&lt;/li&gt;
&lt;li&gt;Backend infrastructure with load balancing&lt;/li&gt;
&lt;li&gt;Fine-tuned models or RAG systems&lt;/li&gt;
&lt;li&gt;Integration with internal systems (CRM, ERP)&lt;/li&gt;
&lt;li&gt;Compliance and security layers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is beyond the scope of a quick integration guide, but consider this path if you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Complete control over the user experience&lt;/li&gt;
&lt;li&gt;On-premise deployment for data security&lt;/li&gt;
&lt;li&gt;Integration with proprietary systems&lt;/li&gt;
&lt;li&gt;Custom model training&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Scaling ChatGPT: What Happens When Traffic Spikes?
&lt;/h2&gt;

&lt;p&gt;This is where most guides fail you. They show a basic embed and call it done. But what happens during a product launch when 10,000 people hit your chatbot simultaneously?&lt;/p&gt;

&lt;h3&gt;
  
  
  OpenAI Rate Limits
&lt;/h3&gt;

&lt;p&gt;OpenAI limits requests based on your account tier:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Requests Per Minute&lt;/th&gt;
&lt;th&gt;Tokens Per Minute&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;40,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tier 1&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;200,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tier 2&lt;/td&gt;
&lt;td&gt;3,500&lt;/td&gt;
&lt;td&gt;2,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tier 5&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;30,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; A sudden traffic spike can exhaust these limits, returning errors to users.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scaling Strategies
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Caching Common Questions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cache responses for frequently asked questions. If 50 people ask "What are your business hours?", you don't need 50 API calls.&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;responseCache&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;Map&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;CACHE_TTL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 1 hour&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getAIResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;question&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;cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;responseCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&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;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;responseCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;CACHE_TTL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&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;response&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;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({...});&lt;/span&gt;
  &lt;span class="nx"&gt;responseCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Queue Systems for Traffic Spikes&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;During high-traffic events, queue requests and process them at a sustainable rate rather than failing immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Use Chat Infrastructure Built for Scale&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is where platforms like &lt;a href="https://deadsimplechat.com" rel="noopener noreferrer"&gt;DeadSimpleChat&lt;/a&gt; come in. Our &lt;a href="https://deadsimplechat.com/features" rel="noopener noreferrer"&gt;chat infrastructure&lt;/a&gt; handles up to 10 million concurrent users. When you integrate ChatGPT via webhooks, the chat layer handles the scale while you control the AI integration rate.&lt;/p&gt;




&lt;h2&gt;
  
  
  Moderating AI Chatbot Responses
&lt;/h2&gt;

&lt;p&gt;Here's something no other guide covers: what happens when your AI chatbot says something wrong, inappropriate, or off-brand?&lt;/p&gt;

&lt;p&gt;ChatGPT can hallucinate. It makes up information that sounds confident but is completely false. According to research by Denser.ai, RAG (Retrieval-Augmented Generation) techniques reduce hallucinations by up to 80%, but they don't eliminate the problem entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Moderation Strategies
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Pre-Response Filtering&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Check AI responses before displaying them to users:&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;BLOCKED_PHRASES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;I cannot help&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;As an AI&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;I don&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;t have access&lt;/span&gt;&lt;span class="dl"&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;BRAND_WARNINGS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;competitor product&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pricing guarantee&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;moderateResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Check for blocked phrases&lt;/span&gt;
  &lt;span class="k"&gt;for &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;phrase&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;BLOCKED_PHRASES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;phrase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;I&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;m not sure about that. Let me connect you with a human agent.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Flag for human review if brand-sensitive&lt;/span&gt;
  &lt;span class="k"&gt;for &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;warning&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;BRAND_WARNINGS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;flagForHumanReview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Human Review Queue&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For high-stakes conversations (sales, complaints, legal questions), route AI responses through human approval before display.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Use Existing Moderation Infrastructure&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're using a chat platform with built-in moderation, leverage it for AI outputs too. DeadSimpleChat's moderation suite includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bad word filters (catch profanity or competitor mentions)&lt;/li&gt;
&lt;li&gt;AI image moderation&lt;/li&gt;
&lt;li&gt;Pre-moderation queues&lt;/li&gt;
&lt;li&gt;Multiple moderator roles&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  When AI Isn't Enough: Human Handoff
&lt;/h2&gt;

&lt;p&gt;According to a CGS study, 86% of customers prefer human agents for complex issues, and 71% would be less likely to purchase without human support available.&lt;/p&gt;

&lt;p&gt;The most effective approach isn't AI-only or human-only. It's hybrid.&lt;/p&gt;

&lt;h3&gt;
  
  
  Escalation Triggers
&lt;/h3&gt;

&lt;p&gt;Set up automatic escalation when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AI confidence is low (detectable via API)&lt;/li&gt;
&lt;li&gt;User explicitly requests a human&lt;/li&gt;
&lt;li&gt;Conversation sentiment turns negative&lt;/li&gt;
&lt;li&gt;Topic is high-stakes (complaints, refunds, legal)&lt;/li&gt;
&lt;li&gt;Multiple failed response attempts
&lt;/li&gt;
&lt;/ul&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;ESCALATION_PHRASES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;speak to human&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;real person&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;manager&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;not helpful&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shouldEscalate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;aiResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;conversationHistory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Check explicit requests&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ESCALATION_PHRASES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;userMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Check conversation length (user might be frustrated)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;conversationHistory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Check for repeated similar questions (AI not resolving)&lt;/span&gt;
  &lt;span class="c1"&gt;// Add more logic as needed&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Hybrid Architecture
&lt;/h3&gt;

&lt;p&gt;The ideal setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;AI handles first contact and common questions&lt;/li&gt;
&lt;li&gt;AI suggests responses to human agents for complex issues&lt;/li&gt;
&lt;li&gt;Seamless handoff when AI can't resolve&lt;/li&gt;
&lt;li&gt;Human agents can "teach" the AI by correcting responses&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is exactly where chat platforms shine. With DeadSimpleChat, you can have AI handling initial responses in a chat room while human moderators jump in when needed - all in the same conversation thread.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Much Does ChatGPT Website Integration Cost?
&lt;/h2&gt;

&lt;p&gt;Let's talk real numbers.&lt;/p&gt;

&lt;h3&gt;
  
  
  No-Code Platform Costs
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Free Tier&lt;/th&gt;
&lt;th&gt;Paid Plans&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chatbase&lt;/td&gt;
&lt;td&gt;100 messages/month&lt;/td&gt;
&lt;td&gt;$19-$399/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Elfsight&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;$6-$25/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Denser.ai&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Custom pricing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CustomGPT&lt;/td&gt;
&lt;td&gt;Trial only&lt;/td&gt;
&lt;td&gt;$49-$299/month&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  API Costs (Direct Integration)
&lt;/h3&gt;

&lt;p&gt;For a typical small business website (1,000 conversations/day, ~500 tokens each):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GPT-4o mini&lt;/strong&gt;: ~$11/month&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPT-4o&lt;/strong&gt;: ~$187/month&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Chat Platform + API Costs
&lt;/h3&gt;

&lt;p&gt;If using a platform like DeadSimpleChat with webhook integration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Platform: &lt;a href="https://deadsimplechat.com/pricing" rel="noopener noreferrer"&gt;See our pricing plans&lt;/a&gt; ($199-$369/month for Growth/Business tiers with API/webhook access)&lt;/li&gt;
&lt;li&gt;OpenAI API: Add based on usage above&lt;/li&gt;
&lt;li&gt;Total: Varies, but scales predictably&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Common Problems and How to Fix Them
&lt;/h2&gt;

&lt;h3&gt;
  
  
  CORS Errors
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Browser blocks API calls to OpenAI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; Never call OpenAI directly from the browser. Always route through your backend.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rate Limit Errors During Traffic Spikes
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; OpenAI returns 429 errors when you exceed rate limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; Implement request queuing, caching, or upgrade your OpenAI tier. For events, pre-warm your account and consider using a chat platform that handles the traffic layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  AI Hallucinations
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Chatbot makes up false information.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; Use RAG (train on your actual data), implement response moderation, and always provide escalation paths to human agents. RAG technology reduces hallucinations by up to 80% according to Denser.ai's research.&lt;/p&gt;

&lt;h3&gt;
  
  
  High Costs
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; API bills unexpectedly high.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; Use GPT-4o mini instead of GPT-4o (16x cheaper). Set spending limits in OpenAI dashboard. Implement caching for common questions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Widget Not Showing on Mobile
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Problem:&lt;/strong&gt; Chat widget doesn't render correctly on mobile devices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; Test embed code on multiple devices. Use responsive positioning. Check z-index conflicts with other elements.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I embed ChatGPT on my website?
&lt;/h3&gt;

&lt;p&gt;Embed ChatGPT using either a no-code platform or the OpenAI API. For no-code, sign up for a platform like Chatbase or Elfsight, train the bot on your data by adding website URLs or documents, customize the appearance, and paste the provided embed code into your website HTML. This process takes 5-15 minutes and requires no coding skills.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I add ChatGPT to my website for free?
&lt;/h3&gt;

&lt;p&gt;Yes, several platforms offer free tiers for ChatGPT website integration. Elfsight, Chatbase, and FwdSlash provide free plans with limited monthly messages (typically 50-500). OpenAI gives new API accounts $5 in credits. For most small businesses testing the waters, free tiers are sufficient to start.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much does it cost to add ChatGPT to a website?
&lt;/h3&gt;

&lt;p&gt;Costs range from free to $1,000+/month depending on usage. No-code platforms cost $0-150/month for most small businesses. OpenAI API charges $2.50 per million input tokens and $10 per million output tokens for GPT-4o. For a typical small business with 1,000 daily chatbot interactions, expect $30-60/month using GPT-4o mini.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need coding skills to embed ChatGPT?
&lt;/h3&gt;

&lt;p&gt;No, coding is not required for basic chatbot embedding. Platforms like Elfsight, Chatbase, and Denser.ai let you create and embed a ChatGPT-powered chatbot without writing any code. However, if you need custom functionality, group chat integration, or scalability features, some development work is required.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the best ChatGPT widget for websites?
&lt;/h3&gt;

&lt;p&gt;The best ChatGPT widget depends on your needs. Chatbase excels at training bots on custom data in under 10 minutes. Elfsight offers the fastest setup with visual configuration. Denser.ai uses RAG technology to reduce AI hallucinations. For group chat scenarios or high-traffic events, webhook integration with a chat platform like DeadSimpleChat provides the most flexibility.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I train ChatGPT on my own website data?
&lt;/h3&gt;

&lt;p&gt;Yes, most ChatGPT embedding platforms let you train the chatbot on your data. You can upload documents (PDFs, Word files), add website URLs for automatic content crawling, or connect knowledge bases. The chatbot then answers questions using your specific information rather than generic internet knowledge. This reduces hallucinations by up to 80% according to Denser.ai's research on RAG technology.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is embedding ChatGPT on my website GDPR compliant?
&lt;/h3&gt;

&lt;p&gt;ChatGPT website integration can be GDPR compliant with proper implementation. You must inform users about data collection, obtain consent before processing personal data, and provide data access and deletion options. GDPR violations can result in fines up to 20 million euros or 4% of global revenue, so review your chatbot provider's data processing agreements carefully.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I add ChatGPT to a group chat or event?
&lt;/h3&gt;

&lt;p&gt;Adding ChatGPT to group conversations requires webhook integration rather than simple widget embedding. Set up a chat platform that supports webhooks (like DeadSimpleChat), configure webhooks to send messages to your server, process messages through OpenAI API, and post responses back to the chat room. This enables AI assistance for community discussions and live events with thousands of users.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can ChatGPT handle high traffic on my website?
&lt;/h3&gt;

&lt;p&gt;OpenAI API has rate limits that vary by account tier (500 to 10,000 requests per minute). For high-traffic websites or live events, implement caching for common questions, use queue systems for traffic spikes, and consider using chat infrastructure built for scale. Platforms like DeadSimpleChat handle up to 10 million concurrent users while you control the AI integration rate.&lt;/p&gt;

&lt;h3&gt;
  
  
  What are the limitations of ChatGPT for websites?
&lt;/h3&gt;

&lt;p&gt;Key limitations include potential hallucinations (making up incorrect information), no real-time data access without custom integrations, API rate limits during traffic spikes, and ongoing costs that scale with usage. ChatGPT also cannot handle complex emotional situations like human agents. Training on custom data, implementing safety guardrails, and providing human escalation paths helps mitigate these issues.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion: Which Method Should You Choose?
&lt;/h2&gt;

&lt;p&gt;Let me make this simple.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose no-code platforms&lt;/strong&gt; if you want a quick chatbot for visitor support and have limited technical resources. Get started in 15 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose OpenAI API direct&lt;/strong&gt; if you have developers and need custom experiences with full control over the conversation flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose webhook integration with a chat platform&lt;/strong&gt; if you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AI in group chat rooms or communities&lt;/li&gt;
&lt;li&gt;Scalability for events with thousands of users&lt;/li&gt;
&lt;li&gt;Moderation capabilities for AI outputs&lt;/li&gt;
&lt;li&gt;Hybrid human + AI support&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The chatbot market is projected to reach $27.29 billion by 2030, growing at 23.3% annually according to Grand View Research. AI-powered website chat isn't a nice-to-have anymore. It's table stakes.&lt;/p&gt;

&lt;p&gt;But remember: 86% of customers still prefer human agents for complex issues. The winning strategy combines AI efficiency with human empathy.&lt;/p&gt;

&lt;p&gt;Ready to add scalable chat with AI integration to your website? &lt;a href="https://deadsimplechat.com/signup" rel="noopener noreferrer"&gt;Try DeadSimpleChat free&lt;/a&gt; - add chat to your site in 5 minutes, scale to millions, and integrate ChatGPT via webhooks. No credit card required.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About the Author&lt;/strong&gt;: DeadSimpleChat has helped thousands of websites add embeddable chat, from small communities to events with 50,000+ concurrent users. Our platform handles up to 10 million concurrent users with full API, SDK, and webhook support for custom integrations like ChatGPT.&lt;/p&gt;




</description>
      <category>ai</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>programming</category>
    </item>
    <item>
      <title>White Label Chat: The Complete Guide to Branded Chat for Your Website [2026]</title>
      <dc:creator>alakkadshaw</dc:creator>
      <pubDate>Thu, 05 Feb 2026 16:56:16 +0000</pubDate>
      <link>https://dev.to/alakkadshaw/white-label-chat-the-complete-guide-to-branded-chat-for-your-website-2026-57e7</link>
      <guid>https://dev.to/alakkadshaw/white-label-chat-the-complete-guide-to-branded-chat-for-your-website-2026-57e7</guid>
      <description>&lt;p&gt;Your chat widget says "Powered by SomeOtherCompany." Your users notice. Your brand takes the hit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;White label chat&lt;/strong&gt; solves this problem. It gives you a fully branded, embeddable chat experience on your website -- without building anything from scratch.&lt;/p&gt;

&lt;p&gt;But here is the thing. Most guides about white label chat focus on chat APIs for developers or customer support tools. They miss the biggest use case entirely: &lt;strong&gt;embeddable group chat for events, communities, and live streaming.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This guide covers what white label chat actually is, why it matters for your brand, and how to choose the right platform. You will also get a transparent pricing comparison and a buyer's checklist you can use today.&lt;/p&gt;

&lt;p&gt;Whether you run virtual events, manage an online community, or embed chat into a SaaS product -- this is the guide you have been looking for.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is White Label Chat?
&lt;/h2&gt;

&lt;p&gt;White label chat is a chat solution you can fully rebrand as your own. You remove the vendor's logo, colors, and "Powered by" watermarks. Your users see your brand -- not someone else's.&lt;/p&gt;

&lt;p&gt;Think of it like ordering a product with your own label on it. The technology runs behind the scenes, but the experience belongs to you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Here is a simple definition:&lt;/strong&gt; White label chat is any chat service you can completely style, brand, and embed on your website as if you built it yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  White Label Chat Is Not the Same As...
&lt;/h3&gt;

&lt;p&gt;The term "white label chat" gets mixed up with other products. Here is how they differ:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;th&gt;Who It Serves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;White label group chat&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Embeddable chat rooms for websites, events, communities&lt;/td&gt;
&lt;td&gt;Event organizers, community managers, SaaS teams&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;White label live chat&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1-to-1 customer support chat widgets&lt;/td&gt;
&lt;td&gt;Support teams, agencies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;White label chatbot&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;AI-powered automated chat agents&lt;/td&gt;
&lt;td&gt;Marketing teams, agencies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Custom-built chat&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Chat built from scratch by developers&lt;/td&gt;
&lt;td&gt;Engineering teams with large budgets&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This guide focuses on &lt;strong&gt;white label group chat&lt;/strong&gt; -- the kind you embed on a website for real-time conversations among multiple users.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffh0ikbukhmjj6twc78lu.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Ffh0ikbukhmjj6twc78lu.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why White Label Chat Matters for Your Brand
&lt;/h2&gt;

&lt;p&gt;Brand consistency is not a nice-to-have. According to a &lt;a href="https://www.rocket.chat/blog/white-label-chat-app" rel="noopener noreferrer"&gt;Lucidpress study&lt;/a&gt;, consistent brand presentation increases revenue by &lt;strong&gt;33%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now picture this. A visitor lands on your beautifully designed website. They click into your community chat or event stream -- and suddenly the interface looks completely different. Different colors, different fonts, someone else's logo.&lt;/p&gt;

&lt;p&gt;That disconnect erodes trust. Fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three Reasons White Label Chat Drives Business Results
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Trust and credibility.&lt;/strong&gt; When every touchpoint looks and feels like your brand, users trust the experience more. They stay longer. They engage more.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Higher engagement and retention.&lt;/strong&gt; Sendbird reports that adding messaging features to a platform &lt;a href="https://sendbird.com/uses/white-label-chat" rel="noopener noreferrer"&gt;increases app retention by 3x&lt;/a&gt;. Branded chat keeps users inside your ecosystem instead of pushing them to third-party platforms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Revenue impact.&lt;/strong&gt; McKinsey research shows businesses that implement customized communication solutions see up to a &lt;a href="https://www.rst.software/blog/white-label-chat" rel="noopener noreferrer"&gt;40% increase in revenue&lt;/a&gt;. White label chat is a direct path to customized communication.&lt;/p&gt;

&lt;p&gt;The bottom line? Your chat should look like yours. Not like a third-party tool awkwardly bolted onto your site.&lt;/p&gt;




&lt;h2&gt;
  
  
  White Label Chat vs. Building Chat From Scratch
&lt;/h2&gt;

&lt;p&gt;This is the classic build-vs-buy question. And the math is not close.&lt;/p&gt;

&lt;p&gt;Building real-time chat from scratch costs between &lt;strong&gt;$30,000 and $300,000+&lt;/strong&gt; depending on complexity. It takes &lt;strong&gt;3 to 9 months&lt;/strong&gt; of development time. And that is just the launch -- ongoing maintenance adds 15-20% annually.&lt;/p&gt;

&lt;p&gt;White label chat? You are looking at &lt;strong&gt;$99 to $500 per month&lt;/strong&gt;, with setup measured in hours or days -- not months.&lt;/p&gt;

&lt;p&gt;Here is the full comparison:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;Build From Scratch&lt;/th&gt;
&lt;th&gt;White Label Chat&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Upfront cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$30,000 - $300,000+&lt;/td&gt;
&lt;td&gt;$0 - $500/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Time to launch&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3 - 9 months&lt;/td&gt;
&lt;td&gt;Hours to days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ongoing maintenance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;In-house team required (15-20% annual cost)&lt;/td&gt;
&lt;td&gt;Handled by vendor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scalability&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You build and manage infrastructure&lt;/td&gt;
&lt;td&gt;Vendor handles scaling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Moderation tools&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Build from scratch&lt;/td&gt;
&lt;td&gt;Included out of the box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Updates and features&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Your responsibility&lt;/td&gt;
&lt;td&gt;Continuous vendor updates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Risk&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;70%+ failure rate for custom enterprise software&lt;/td&gt;
&lt;td&gt;Proven, production-ready platform&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That 70% failure rate is not a typo. Custom enterprise software projects fail at alarming rates, &lt;a href="https://www.rocket.chat/blog/white-label-chat-app" rel="noopener noreferrer"&gt;according to industry data cited by Rocket.Chat&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For most teams, building chat in-house means spending months of engineering time on a problem that has already been solved. White label chat lets you skip straight to the result.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Skip the build.&lt;/strong&gt; &lt;a href="https://deadsimplechat.com/signup" rel="noopener noreferrer"&gt;Try DeadSimpleChat's white-label chat free&lt;/a&gt; -- add branded chat to your website in minutes.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Key Features to Look for in White Label Chat
&lt;/h2&gt;

&lt;p&gt;Not all white label chat platforms are equal. Before you choose one, run through this checklist.&lt;/p&gt;

&lt;h3&gt;
  
  
  Branding and Customization
&lt;/h3&gt;

&lt;p&gt;This is the whole point of going white label. Look for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Full logo and color customization&lt;/strong&gt; -- your brand, not theirs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSS theming&lt;/strong&gt; -- control fonts, spacing, and layout to match your site exactly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No "Powered by" watermarks&lt;/strong&gt; -- complete removal of vendor branding&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom domain support&lt;/strong&gt; -- chat runs on your domain, not the vendor's&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Moderation Tools
&lt;/h3&gt;

&lt;p&gt;If your chat handles more than a handful of users, moderation is non-negotiable.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ban and unban users&lt;/li&gt;
&lt;li&gt;Delete messages in real time&lt;/li&gt;
&lt;li&gt;Bad word filters (automatic)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI-based image moderation&lt;/strong&gt; -- blocks inappropriate images before they appear&lt;/li&gt;
&lt;li&gt;Multiple moderator roles&lt;/li&gt;
&lt;li&gt;Pre-moderation capabilities (approve messages before they go live)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqkr3x59ed4x4fbsuqw7n.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fqkr3x59ed4x4fbsuqw7n.png" alt=" " width="800" height="544"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Scalability
&lt;/h3&gt;

&lt;p&gt;Ask the hard question: &lt;strong&gt;how many concurrent users can it actually handle?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Some platforms cap out at a few hundred users. That works for a small community. It does not work for a live event with 10,000 attendees.&lt;/p&gt;

&lt;p&gt;Look for platforms that scale from small communities to large-scale events without requiring you to change infrastructure or plans.&lt;/p&gt;

&lt;h3&gt;
  
  
  SSO and User Authentication
&lt;/h3&gt;

&lt;p&gt;Single Sign-On matters more than most buyers realize. With SSO, your users log into your platform once -- and they are automatically authenticated in the chat. No second login. No friction.&lt;/p&gt;

&lt;p&gt;This is critical for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SaaS applications where users already have accounts&lt;/li&gt;
&lt;li&gt;Membership sites and online communities&lt;/li&gt;
&lt;li&gt;Virtual events with registered attendees&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Other Must-Have Features
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Embedding options&lt;/strong&gt; -- iframe, JavaScript snippet, or full API/SDK&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile responsiveness&lt;/strong&gt; -- chat must work on every device&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File and media sharing&lt;/strong&gt; -- photos, GIFs, audio messages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analytics and reporting&lt;/strong&gt; -- track engagement, message volume, active users&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Password-protected rooms&lt;/strong&gt; -- for private sessions or premium content&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks&lt;/strong&gt; -- trigger actions in your app when chat events happen&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Best White Label Chat Platforms [2026]
&lt;/h2&gt;

&lt;p&gt;Here is a transparent comparison of the top white label chat platforms available right now. We focus on platforms that offer &lt;strong&gt;embeddable group chat&lt;/strong&gt; -- not customer support tools or AI chatbots.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. DeadSimpleChat
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Virtual events, online communities, live streaming, SaaS platforms&lt;/p&gt;

&lt;p&gt;&lt;a href="https://deadsimplechat.com/" rel="noopener noreferrer"&gt;DeadSimpleChat&lt;/a&gt; is an embeddable chat platform built for group conversations on websites. It scales from 5 users on the free tier to &lt;strong&gt;10 million concurrent users&lt;/strong&gt; on Enterprise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key strengths:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;White label included&lt;/strong&gt; -- remove all branding, add your logo, customize with CSS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Massive scalability&lt;/strong&gt; -- up to 10M concurrent users, no infrastructure changes needed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full moderation suite&lt;/strong&gt; -- ban/unban, word filters, AI image moderation, multiple moderators&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSO integration&lt;/strong&gt; -- authenticate users from your existing platform automatically&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Easy embed&lt;/strong&gt; -- add chat to any website with a JavaScript snippet or iframe&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API, SDK, and webhooks&lt;/strong&gt; -- build custom experiences on top of the platform&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Daily pricing&lt;/strong&gt; -- pay per day for one-off events instead of monthly subscriptions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Concurrent Users&lt;/th&gt;
&lt;th&gt;Rooms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;$0/mo&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Growth&lt;/td&gt;
&lt;td&gt;$199/mo&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Business&lt;/td&gt;
&lt;td&gt;$369/mo&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;1,000+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;10,000,000&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Why it stands out:&lt;/strong&gt; DeadSimpleChat is the only white label chat platform that combines embeddable group chat, enterprise-grade scalability, and a complete moderation suite -- with a free tier to start. Check out the full &lt;a href="https://deadsimplechat.com/features" rel="noopener noreferrer"&gt;feature list&lt;/a&gt; or &lt;a href="https://deadsimplechat.com/pricing" rel="noopener noreferrer"&gt;see pricing&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. TalkJS
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Developers building in-app messaging (1-to-1 and group)&lt;/p&gt;

&lt;p&gt;TalkJS offers pre-built chat UI components with white-label capabilities. It is API-driven and developer-focused.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pre-built UI with customization options&lt;/li&gt;
&lt;li&gt;$279/month for 10,000 MAU (basic tier)&lt;/li&gt;
&lt;li&gt;Strong documentation and developer tools&lt;/li&gt;
&lt;li&gt;Less suited for embeddable event or community chat&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Sendbird
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Enterprise apps with large-scale messaging needs&lt;/p&gt;

&lt;p&gt;Sendbird is a high-end chat API platform with strong white-label support. It targets enterprise mobile and web applications.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enterprise-grade infrastructure&lt;/li&gt;
&lt;li&gt;Comprehensive SDKs for iOS, Android, and web&lt;/li&gt;
&lt;li&gt;Higher price point (custom pricing for most plans)&lt;/li&gt;
&lt;li&gt;Requires significant development effort to implement&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Rocket.Chat
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams that want self-hosted, open-source chat&lt;/p&gt;

&lt;p&gt;Rocket.Chat is an open-source messaging platform you can host yourself. White-labeling requires self-hosting and technical configuration.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free and open source (Community Edition)&lt;/li&gt;
&lt;li&gt;Full control over branding and data&lt;/li&gt;
&lt;li&gt;Requires DevOps expertise to deploy and maintain&lt;/li&gt;
&lt;li&gt;Self-hosting costs can reach $1,000-$5,000/month for infrastructure&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Stream (GetStream.io)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Developers building custom chat UIs from components&lt;/p&gt;

&lt;p&gt;Stream provides chat API infrastructure with UI component kits. Pricing starts at $499/month.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Powerful API with flexible UI components&lt;/li&gt;
&lt;li&gt;Strong developer documentation&lt;/li&gt;
&lt;li&gt;Higher cost -- $499/month starting tier&lt;/li&gt;
&lt;li&gt;Requires substantial development to implement&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Platform Comparison at a Glance
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;White Label&lt;/th&gt;
&lt;th&gt;Max Scale&lt;/th&gt;
&lt;th&gt;Moderation Suite&lt;/th&gt;
&lt;th&gt;Easy Embed&lt;/th&gt;
&lt;th&gt;Free Tier&lt;/th&gt;
&lt;th&gt;Starting Price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DeadSimpleChat&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;10M users&lt;/td&gt;
&lt;td&gt;Yes (AI included)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;$0/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TalkJS&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;N/A (API)&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;No (API)&lt;/td&gt;
&lt;td&gt;Trial&lt;/td&gt;
&lt;td&gt;$279/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sendbird&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No (API)&lt;/td&gt;
&lt;td&gt;Trial&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rocket.Chat&lt;/td&gt;
&lt;td&gt;Full (self-host)&lt;/td&gt;
&lt;td&gt;Depends on infra&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (OSS)&lt;/td&gt;
&lt;td&gt;Free + hosting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stream&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;td&gt;No (API)&lt;/td&gt;
&lt;td&gt;Trial&lt;/td&gt;
&lt;td&gt;$499/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo4x7w5gtrfainrwqnh71.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fo4x7w5gtrfainrwqnh71.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  White Label Chat Use Cases
&lt;/h2&gt;

&lt;p&gt;White label chat is not a one-size-fits-all product. The use case determines which features matter most. Here is where it shines.&lt;/p&gt;

&lt;h3&gt;
  
  
  Virtual Events and Conferences
&lt;/h3&gt;

&lt;p&gt;Live events need chat that scales fast, works for a few hours, and looks like part of the event platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What matters most:&lt;/strong&gt; Scalability (thousands of concurrent users), daily pricing (pay only for the event), moderation at scale, and branded experience that matches the event page.&lt;/p&gt;

&lt;p&gt;DeadSimpleChat supports up to 10 million concurrent users and offers daily pricing for one-off events -- so you do not pay a monthly subscription for a single-day conference. Learn more about &lt;a href="https://deadsimplechat.com/virtual-event-chat" rel="noopener noreferrer"&gt;chat for virtual events&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Online Communities
&lt;/h3&gt;

&lt;p&gt;Community chat needs to be always-on, persistent, and deeply branded. Members should feel like they are in your space -- not on someone else's platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What matters most:&lt;/strong&gt; Persistent chat rooms, member management, full branding customization, and the ability to create multiple rooms or channels (e.g., topic-specific discussions, member-only areas).&lt;/p&gt;

&lt;h3&gt;
  
  
  Live Streaming
&lt;/h3&gt;

&lt;p&gt;Companion chat alongside a video stream is now expected by audiences. Think Twitch-style chat, but on your own platform with your own branding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What matters most:&lt;/strong&gt; Real-time messaging at scale, reactions and media sharing, aggressive moderation tools (live streams attract spam), and mobile responsiveness.&lt;/p&gt;

&lt;h3&gt;
  
  
  SaaS Applications
&lt;/h3&gt;

&lt;p&gt;SaaS products that need in-app chat -- think marketplaces, education platforms, fintech dashboards -- benefit from white label chat with SSO and API access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What matters most:&lt;/strong&gt; SSO integration (users authenticate through your app), API and SDK access for custom workflows, &lt;a href="https://deadsimplechat.com/features" rel="noopener noreferrer"&gt;webhooks for real-time notifications&lt;/a&gt;, and complete branding control so the chat feels native.&lt;/p&gt;

&lt;h3&gt;
  
  
  Education
&lt;/h3&gt;

&lt;p&gt;Classroom chat, study groups, and course discussions require privacy controls and moderation suited for educational settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What matters most:&lt;/strong&gt; Password-protected rooms, pre-moderation, file sharing for educational materials, and the ability to create sub-rooms for different classes or cohorts.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Set Up White Label Chat on Your Website
&lt;/h2&gt;

&lt;p&gt;Setting up white label chat does not require a development team. Here is the process using DeadSimpleChat as an example.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Create your account.&lt;/strong&gt; &lt;a href="https://deadsimplechat.com/signup" rel="noopener noreferrer"&gt;Sign up for free&lt;/a&gt; -- no credit card required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Create a chat room.&lt;/strong&gt; Give it a name and configure basic settings (public or private, password protection, etc.).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Customize the branding.&lt;/strong&gt; Upload your logo, set your brand colors, and adjust the CSS to match your website design. Remove all DeadSimpleChat branding for a fully white-label experience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Embed the chat.&lt;/strong&gt; Copy the embed code (iframe or JavaScript snippet) and paste it into your website. The chat appears instantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5: Configure moderation.&lt;/strong&gt; Set up word filters, enable AI image moderation, and assign moderator roles to your team members.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 6: Connect SSO (optional).&lt;/strong&gt; If your platform has user accounts, configure SSO so users are automatically authenticated in the chat.&lt;/p&gt;

&lt;p&gt;The entire process takes minutes, not months. And you can preview changes in real time before going live.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Ready to try it?&lt;/strong&gt; &lt;a href="https://deadsimplechat.com/signup" rel="noopener noreferrer"&gt;Get started with DeadSimpleChat for free&lt;/a&gt; -- embed white label chat on your website in under 5 minutes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9l9ky0wb0uu3ifhbevav.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2F9l9ky0wb0uu3ifhbevav.png" alt=" " width="800" height="266"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  How Much Does White Label Chat Cost?
&lt;/h2&gt;

&lt;p&gt;Pricing transparency is rare in this space. Here is what you can actually expect to pay.&lt;/p&gt;

&lt;h3&gt;
  
  
  White Label Chat SaaS Pricing
&lt;/h3&gt;

&lt;p&gt;Most white label chat platforms charge between &lt;strong&gt;$99 and $500 per month&lt;/strong&gt; for plans that include branding removal. Some offer free tiers with limited features.&lt;/p&gt;

&lt;p&gt;DeadSimpleChat starts at &lt;strong&gt;$0/month&lt;/strong&gt; (free tier with 5 concurrent users) and scales to custom Enterprise pricing for organizations that need millions of concurrent users.&lt;/p&gt;

&lt;h3&gt;
  
  
  Custom Development Costs
&lt;/h3&gt;

&lt;p&gt;Building chat from scratch? Budget for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Simple chat app:&lt;/strong&gt; $30,000 - $65,000 (3-6 months development)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complex platform:&lt;/strong&gt; $250,000+ (9+ months development)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Annual maintenance:&lt;/strong&gt; 15-20% of initial development cost&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure:&lt;/strong&gt; Ongoing server, DevOps, and monitoring costs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These numbers come from &lt;a href="https://talkjs.com/resources/white-label-chat/" rel="noopener noreferrer"&gt;TalkJS's comprehensive cost analysis&lt;/a&gt; and are consistent across multiple industry sources.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Real Cost Comparison
&lt;/h3&gt;

&lt;p&gt;A SaaS white label chat platform at $200/month costs &lt;strong&gt;$2,400 per year&lt;/strong&gt;. Custom development at the low end costs &lt;strong&gt;$30,000 upfront&lt;/strong&gt; plus $4,500-$6,000 in annual maintenance.&lt;/p&gt;

&lt;p&gt;That means white label chat pays for itself in the first year -- and saves you more every year after.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is white label chat?
&lt;/h3&gt;

&lt;p&gt;White label chat is a chat solution you can fully rebrand with your own logo, colors, and styling. It removes the vendor's branding so the chat looks like a native part of your website or application.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much does white label chat cost?
&lt;/h3&gt;

&lt;p&gt;SaaS white label chat platforms typically cost $99-$500/month. DeadSimpleChat offers a free tier and paid plans starting at $199/month. Custom-built chat costs $30,000-$300,000+ upfront.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between white label chat and custom-built chat?
&lt;/h3&gt;

&lt;p&gt;White label chat is a ready-made platform you rebrand. Custom-built chat is developed from scratch by your engineering team. White label is faster, cheaper, and lower risk. Custom gives you full control but requires significant investment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is white label chat secure?
&lt;/h3&gt;

&lt;p&gt;Reputable white label chat platforms use encryption for data in transit and at rest, offer SSO integration, provide IP whitelisting, and comply with regulations like GDPR. Always verify your vendor's security practices before committing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I remove all branding from a chat widget?
&lt;/h3&gt;

&lt;p&gt;Yes -- true white label chat platforms let you remove all vendor branding, including logos, "Powered by" text, and email notification branding. DeadSimpleChat supports full branding removal on paid plans.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the best white label chat for events?
&lt;/h3&gt;

&lt;p&gt;For virtual events and conferences, look for white label chat with massive scalability, daily pricing options, real-time moderation, and easy embedding. DeadSimpleChat supports up to 10 million concurrent users with daily pricing for one-off events.&lt;/p&gt;




&lt;h2&gt;
  
  
  Choosing the Right White Label Chat Platform
&lt;/h2&gt;

&lt;p&gt;Here is a quick decision framework to narrow down your options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose an embeddable white label chat (like DeadSimpleChat) if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need group chat on your website, event page, or community platform&lt;/li&gt;
&lt;li&gt;You want to embed chat without heavy development work&lt;/li&gt;
&lt;li&gt;Scalability matters (hundreds to millions of users)&lt;/li&gt;
&lt;li&gt;You need built-in moderation tools&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Choose a chat API/SDK (like TalkJS or Sendbird) if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have a development team ready to build custom UI&lt;/li&gt;
&lt;li&gt;You need deeply integrated in-app messaging&lt;/li&gt;
&lt;li&gt;Your use case requires complex custom workflows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Choose self-hosted open source (like Rocket.Chat) if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need full control over data and infrastructure&lt;/li&gt;
&lt;li&gt;You have DevOps expertise in-house&lt;/li&gt;
&lt;li&gt;Compliance requirements demand on-premises hosting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most teams building websites, running events, or managing communities, an embeddable white label chat platform delivers the fastest results with the least effort.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;White label chat gives your website a branded, professional chat experience without the cost, risk, or timeline of building from scratch.&lt;/p&gt;

&lt;p&gt;The market is growing fast. The global chat software market is valued at &lt;a href="https://www.marketgrowthreports.com/market-reports/instant-messaging-and-chat-software-market-104935" rel="noopener noreferrer"&gt;$34.5 billion in 2026&lt;/a&gt; and projected to reach $76.8 billion by 2035. Adding branded chat to your platform is not a luxury -- it is a competitive requirement.&lt;/p&gt;

&lt;p&gt;The key is choosing a platform that matches your use case. If you need embeddable group chat for events, communities, or live streaming, look for a solution that combines white label branding, scalability, moderation, and simple embedding.&lt;/p&gt;

&lt;p&gt;DeadSimpleChat checks every box. It is the only white label chat platform purpose-built for embeddable group chat -- scaling from 5 users to 10 million concurrent, with a complete moderation suite and full branding control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://deadsimplechat.com/signup" rel="noopener noreferrer"&gt;Try DeadSimpleChat free today&lt;/a&gt;&lt;/strong&gt; -- add white label chat to your website in minutes. No credit card required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thank you for reading.
&lt;/h2&gt;

</description>
      <category>webdev</category>
      <category>beginners</category>
      <category>javascript</category>
      <category>html</category>
    </item>
    <item>
      <title>7 WebRTC Trends Shaping Real-Time Communication in 2026</title>
      <dc:creator>alakkadshaw</dc:creator>
      <pubDate>Mon, 02 Feb 2026 18:12:45 +0000</pubDate>
      <link>https://dev.to/alakkadshaw/7-webrtc-trends-shaping-real-time-communication-in-2026-1o07</link>
      <guid>https://dev.to/alakkadshaw/7-webrtc-trends-shaping-real-time-communication-in-2026-1o07</guid>
      <description>&lt;p&gt;The WebRTC market is experiencing explosive growth in 2026. According to Technavio, the market is projected to expand by USD 247.7 billion from 2025 to 2029, representing a staggering 62.6% compound annual growth rate. These aren't just incremental shifts—the WebRTC trends in 2026 represent a fundamental transformation of how real-time communication infrastructure works at scale.&lt;/p&gt;

&lt;p&gt;WebRTC (Web Real-Time Communication) enables peer-to-peer audio, video, and data sharing directly in web browsers without plugins or native apps. It's the invisible infrastructure powering video calls, live streaming, telehealth consultations, and collaborative tools used by billions of people daily. At the core of reliable WebRTC connectivity is a &lt;a href="https://www.metered.ca/blog/what-is-a-turn-server-3/" rel="noopener noreferrer"&gt;TURN server&lt;/a&gt;—the relay that ensures connections work even behind restrictive NATs and firewalls.&lt;/p&gt;

&lt;p&gt;Why 2025 was a pivotal year? Three forces are converging: AI integration is moving from experimental to production, new protocols like Media over QUIC are reshaping streaming architecture, and market adoption is accelerating across industries from telehealth to IoT.&lt;/p&gt;

&lt;p&gt;Here are the 7 trends defining WebRTC in 2026:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;AI &amp;amp; Machine Learning Integration&lt;/strong&gt; — Real-time translation, noise suppression, and voice agents&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Media over QUIC (MoQ) Protocol Emergence&lt;/strong&gt; — Combining WebRTC latency with broadcast scale&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Codec Evolution&lt;/strong&gt; — AV1, VP9, and H.265 bandwidth optimization&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IoT &amp;amp; Edge Computing&lt;/strong&gt; — 18 billion devices by year-end&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AR/VR/XR Expansion&lt;/strong&gt; — Spatial audio and cross-platform immersive experiences&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security &amp;amp; Privacy Enhancements&lt;/strong&gt; — DTLS 1.3 migration and SFrame E2EE&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Market Growth &amp;amp; Industry Adoption&lt;/strong&gt; — Telehealth, enterprise, and SME acceleration&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;From an infrastructure operator's perspective, these trends have profound implications for TURN relay architecture, bandwidth economics, and global connectivity. Let's explore what's really happening beneath the surface.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmkvxiceme76zjq4rim0o.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fmkvxiceme76zjq4rim0o.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Trend 1 — AI &amp;amp; Machine Learning Integration: The Dominant Force
&lt;/h2&gt;

&lt;p&gt;AI integration isn't just a trend—it's reshaping the entire WebRTC landscape. By 2024, WebRTC already underpinned 89% of real-time internet communication, and the market is projected to surge from $19.4 billion in 2025 to $755.5 billion by 2035, driven primarily by AI applications.&lt;/p&gt;

&lt;p&gt;But here's what most coverage misses: the infrastructure requirements are fundamentally different.&lt;/p&gt;

&lt;h3&gt;
  
  
  OpenAI Realtime API and WebRTC
&lt;/h3&gt;

&lt;p&gt;In December 2024, OpenAI announced WebRTC Endpoint support for their Realtime API. This closed a critical gap for integrating large language models with real-time voice communication. Now developers can build AI voice agents that respond to users through WebRTC connections with minimal latency.&lt;/p&gt;

&lt;p&gt;The use cases are already emerging. Conversational AI assistants that handle customer service calls in real-time. Voice-first applications where users speak naturally to AI systems. Interactive tutoring platforms where AI responds instantly to student questions.&lt;/p&gt;

&lt;p&gt;Here's the catch: &lt;strong&gt;AI voice agents demand sub-300ms end-to-end latency for natural conversation&lt;/strong&gt;. That's significantly stricter than typical WebRTC video calls, where 500-800ms is often acceptable. When you're talking to an AI, every 100ms of additional delay breaks the illusion of natural interaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Practical AI Applications in WebRTC
&lt;/h3&gt;

&lt;p&gt;AI is enhancing WebRTC in ways that were science fiction just two years ago.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-time translation&lt;/strong&gt; now works during live video calls. Machine learning models automatically translate spoken language as people speak, enabling seamless multilingual conversations. Japanese and English speakers can collaborate in real-time without either learning the other's language.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Noise suppression&lt;/strong&gt; has evolved beyond simple filters. ML models isolate human voices from ambient noise—barking dogs, construction sounds, keyboard typing—and suppress them in real-time without degrading voice quality. The model learns what's "voice" and what's "noise" and adapts continuously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Video upscaling&lt;/strong&gt; improves low-resolution streams on the fly. When someone joins from a poor connection or older device, AI models enhance the video quality dynamically, adjusting compression based on content complexity. A static talking head gets more compression than a screen share with detailed text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sentiment analysis&lt;/strong&gt; is being deployed in customer service applications. The system gauges emotions through tone, pitch, and content, alerting human agents when users become frustrated. This allows preemptive intervention before customers churn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sign language translation&lt;/strong&gt; represents a breakthrough for accessibility. Real-time computer vision models can interpret sign language and convert it to speech or text, enabling deaf and hard-of-hearing users to participate in voice calls without human interpreters.&lt;/p&gt;

&lt;h3&gt;
  
  
  Technical Implementation
&lt;/h3&gt;

&lt;p&gt;How does this actually work? TensorFlow.js enables developers to run machine learning models directly in web browsers. This means AI processing can happen client-side without round-tripping to a server, reducing latency and protecting privacy.&lt;/p&gt;

&lt;p&gt;Edge AI integration is accelerating this trend. Instead of centralizing all processing in the cloud, computation happens at the network edge—closer to users. This decentralizes the load, reduces latency, and improves reliability when cloud connectivity is intermittent.&lt;/p&gt;

&lt;p&gt;The architecture looks like this: browser captures audio/video → TensorFlow.js model processes locally → enhanced stream sent over WebRTC → recipient receives improved quality. All in real-time, all while maintaining sub-300ms latency.&lt;/p&gt;

&lt;h3&gt;
  
  
  Infrastructure Implications: The Hidden Challenge
&lt;/h3&gt;

&lt;p&gt;Here's what the AI hype doesn't mention: &lt;strong&gt;global TURN relay architecture becomes critical when you need &amp;lt;300ms latency&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Consider the scenario: A user in Singapore talks to an AI voice agent hosted in US-East. The round-trip network latency alone—Singapore to Virginia and back—is roughly 200-250ms under ideal conditions. Add encoding, decoding, and processing time, and you're already approaching or exceeding the 300ms budget.&lt;/p&gt;

&lt;p&gt;The solution? Global TURN relay with optimized routing. When the user in Singapore connects through a local TURN server, and that TURN server has a private, high-speed connection to the region hosting the AI, you can shave 50-100ms off the total latency. That's the difference between natural conversation and noticeable lag.&lt;/p&gt;

&lt;p&gt;AI voice agents also create different traffic patterns than traditional peer-to-peer WebRTC. Instead of bursty video calls that last 20-40 minutes, AI applications often involve sustained connections with unpredictable spikes. A customer service AI might handle hundreds of simultaneous conversations, each requiring low-latency relay.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bandwidth considerations matter too.&lt;/strong&gt; While the audio itself is lightweight (typically 32-64 kbps), AI-enhanced video with real-time upscaling can demand 2-3x typical bitrates during processing. Infrastructure needs to handle these bursts without degrading quality.&lt;/p&gt;

&lt;p&gt;The economics are shifting as well. Traditional WebRTC operates on a peer-to-peer model where TURN relay is only needed when direct connection fails (roughly 15-20% of cases). AI voice agents &lt;strong&gt;always&lt;/strong&gt; go through infrastructure—there is no peer-to-peer fallback. This means 100% of traffic hits TURN servers, fundamentally changing cost modeling and capacity planning.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trend 2 — Media over QUIC (MoQ): Protocol Evolution
&lt;/h2&gt;

&lt;p&gt;A new protocol is emerging that could reshape streaming architecture. Media over QUIC (MoQ) combines the low latency of WebRTC with the scale of traditional streaming protocols like HLS and DASH, all while simplifying the technical complexity that has plagued real-time streaming for years.&lt;/p&gt;

&lt;p&gt;But before you rip out your WebRTC infrastructure, here's the reality check: MoQ is promising, but production readiness is still 2026+.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is Media over QUIC?
&lt;/h3&gt;

&lt;p&gt;MoQ is an open protocol being developed at the IETF by engineers from Google, Meta, Cisco, Akamai. The goal is ambitious: solve what's been called the "historical trilemma" of streaming.&lt;/p&gt;

&lt;p&gt;For decades, you could have two of these three, but not all three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sub-second latency&lt;/strong&gt; (like WebRTC)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Broadcast scale&lt;/strong&gt; (like HLS/DASH serving millions of viewers)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architectural simplicity&lt;/strong&gt; (not requiring complex server-side processing)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Traditional WebRTC gives you low latency but struggles at broadcast scale—sending 1080p video to 100,000 viewers simultaneously is expensive and complex. HLS/DASH scales beautifully to millions of viewers but has 10-30 seconds of latency. RTMP was simple but had neither scale nor latency.&lt;/p&gt;

&lt;p&gt;MoQ aims to deliver all three by treating media as subscribable tracks in a publish/subscribe system designed specifically for real-time media at CDN scale. Instead of point-to-point connections, media flows through relay entities that can cache, forward, and distribute efficiently.&lt;/p&gt;

&lt;h3&gt;
  
  
  MoQ vs WebRTC — Complementary, Not Competitive
&lt;/h3&gt;

&lt;p&gt;Here's a key insight that gets missed in breathless coverage: &lt;strong&gt;MoQ and WebRTC are complementary technologies, not competitors&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;WebRTC excels at interactive, bidirectional communication. Think video conferencing where everyone can talk, screen sharing in collaborative tools, or peer-to-peer file transfers. The interactivity is the point—low latency matters because participants need to respond to each other in real-time.&lt;/p&gt;

&lt;p&gt;MoQ is designed for scalable, broadcast-scale streaming with sub-second latency. Think live sports streaming to millions, concert broadcasts where viewers don't need to talk back, or large-scale webinars where one presenter addresses thousands. The distribution is the point—reaching massive audiences while maintaining live-like latency.&lt;/p&gt;

&lt;p&gt;The decision framework is straightforward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use WebRTC when:&lt;/strong&gt; You need bidirectional communication, fewer than 100 participants, or interactive features like screen sharing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use MoQ when:&lt;/strong&gt; You need to stream to thousands or millions, viewers don't need to send media back, or you want CDN-friendly distribution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some applications will use both. A large webinar might use MoQ to broadcast the presenter to 10,000 viewers, while using WebRTC for the Q&amp;amp;A panel of 5-10 speakers who need to interact.&lt;/p&gt;

&lt;h3&gt;
  
  
  Production Status &amp;amp; Browser Support: The 2026 Reality Check
&lt;/h3&gt;

&lt;p&gt;But here's where we need to be cautiously optimistic rather than prematurely enthusiastic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser support is incomplete.&lt;/strong&gt; Chrome and Edge (Chromium-based browsers) support WebTransport, which MoQ relies on. Safari doesn't yet have fully functional WebTransport support, though Apple has indicated their intent to implement it. Until Safari supports it, you're cutting off a significant chunk of mobile and desktop users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production readiness is still developing.&lt;/strong&gt; As of December 2024, industry consensus is that MoQ isn't quite ready for production use cases, though it's coming soon given current momentum. Red5, a major streaming platform vendor, plans to support MoQ by the end of 2025—that's a concrete timeline indicating when production deployment becomes realistic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The workhorses are still VP8 and H.264.&lt;/strong&gt; For all the excitement around new protocols, the vast majority of WebRTC traffic in 2025 runs on battle-tested codecs and proven architectures. MoQ represents the future, but that future is 2026 and beyond, not today.&lt;/p&gt;

&lt;p&gt;This doesn't mean ignore MoQ. It means watch this space, understand the architecture, and prepare your infrastructure to adapt when adoption reaches critical mass. Early movers who understand MoQ will have competitive advantages when it matures.&lt;/p&gt;

&lt;h3&gt;
  
  
  Infrastructure Implications: How TURN Adapts
&lt;/h3&gt;

&lt;p&gt;What does MoQ mean for TURN relay infrastructure? The architecture is different but the need for relay doesn't disappear—it transforms.&lt;/p&gt;

&lt;p&gt;MoQ introduces &lt;strong&gt;relay entities&lt;/strong&gt; that forward media over QUIC or HTTP/3. These aren't traditional TURN servers, but they serve a similar function: relaying media when direct delivery isn't optimal. The key difference is that MoQ relays are designed to work seamlessly with CDNs, allowing existing CDN infrastructure to be upgraded rather than replaced.&lt;/p&gt;

&lt;p&gt;For infrastructure operators, this means planning for dual-protocol support. WebRTC TURN servers for interactive use cases will coexist with MoQ relay entities for broadcast scenarios. The two protocols handle different problems, so the infrastructure to support both will be necessary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost model shifts slightly.&lt;/strong&gt; MoQ's CDN-friendly design means caching becomes possible—the same media stream can be cached at edge locations and delivered to multiple viewers from cache. Traditional TURN relay doesn't allow caching because every connection is unique. This could reduce bandwidth costs for broadcast scenarios while maintaining low latency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Geographic distribution remains critical.&lt;/strong&gt; Just like WebRTC benefits from global TURN relay, MoQ will benefit from globally distributed relay entities. Users in APAC shouldn't have to pull streams from US-East—they should hit a local relay that caches or forwards efficiently.&lt;/p&gt;

&lt;p&gt;The timeline for infrastructure adaptation is 2026+. Operators can monitor MoQ development, test implementations as they mature, and plan for gradual integration. The transition will be evolutionary, not revolutionary—WebRTC isn't going anywhere, and MoQ will supplement rather than replace it for the foreseeable future.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trend 3 — Codec Evolution: AV1, VP9, and the Reality Check
&lt;/h2&gt;

&lt;p&gt;Video codecs determine how much bandwidth real-time communication consumes. In 2025, a new generation of codecs promises massive bandwidth savings—but the reality is more nuanced than the hype suggests.&lt;/p&gt;

&lt;h3&gt;
  
  
  AV1 — Promise vs Reality
&lt;/h3&gt;

&lt;p&gt;AV1 is the darling of codec discussions. Developed by the Alliance for Open Media (a consortium including Google, Mozilla, Cisco, and others), AV1 is royalty-free and delivers impressive compression efficiency. At equivalent video quality, AV1 reduces file sizes by 30-50% compared to VP9 and H.265.&lt;/p&gt;

&lt;p&gt;The bandwidth savings are real. Testing shows AV1 performs exceptionally well at low bitrates—200 to 600 kbps—maintaining excellent visual quality even under constrained bandwidth conditions. For users on mobile networks or in regions with poor connectivity, this is transformative.&lt;/p&gt;

&lt;p&gt;Here's the reality check: &lt;strong&gt;AV1 encoding is 5 to 10 times slower than VP9&lt;/strong&gt;, and CPU usage can peak at 225% during active encoding. That's not a typo—it's more than double the CPU load compared to VP9.&lt;/p&gt;

&lt;p&gt;For live, real-time applications like video conferencing, this matters enormously. You can't pre-encode AV1 content in advance like you can for video-on-demand. The encoding must happen in real-time as users speak, and if your device can't keep up, the stream degrades or drops frames.&lt;/p&gt;

&lt;p&gt;Hardware acceleration is improving. Newer GPUs and dedicated encoding chips are adding AV1 support, which brings CPU usage down to manageable levels. But hardware support isn't universal yet—especially on mobile devices and older laptops that are still widely used in 2025.&lt;/p&gt;

&lt;p&gt;The practical takeaway? AV1 is coming, but it's not the default for real-time WebRTC in 2025. It's being adopted gradually, particularly in scenarios where users have modern hardware and bandwidth is constrained. Think mobile networks in developing markets, or high-quality screen sharing where text clarity matters more than smooth motion.&lt;/p&gt;

&lt;h3&gt;
  
  
  VP9 — The Workhorse
&lt;/h3&gt;

&lt;p&gt;While everyone talks about AV1, VP9 quietly powers the majority of high-quality WebRTC streams in 2025. Why? It strikes the best balance between compression efficiency, CPU usage, and feature support.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VP9 is the only codec in WebRTC that supports Scalable Video Coding (SVC).&lt;/strong&gt; SVC allows a single video stream to be encoded at multiple quality levels simultaneously, and recipients can subscribe to the layer that matches their bandwidth and device capabilities.&lt;/p&gt;

&lt;p&gt;This is critical for large group video calls and live broadcasts. Instead of encoding three separate streams (high, medium, low quality), you encode once with SVC, and the server forwards the appropriate layer to each participant. It's vastly more efficient for group scenarios.&lt;/p&gt;

&lt;p&gt;VP9 also has mature hardware support across devices. Nearly all modern smartphones, laptops, and browsers can encode and decode VP9 efficiently. The ecosystem is battle-tested and stable.&lt;/p&gt;

&lt;p&gt;For most WebRTC deployments in 2025, VP9 remains the ideal choice for group calls, webinars, and any scenario requiring SVC. The compression is good (not quite as good as AV1, but close), CPU usage is reasonable, and it just works reliably across the ecosystem.&lt;/p&gt;

&lt;h3&gt;
  
  
  H.265 (HEVC) — The Enterprise Option
&lt;/h3&gt;

&lt;p&gt;H.265 (also known as HEVC) is an interesting middle ground. It offers strong compression efficiency—close to VP9—and has excellent hardware encoder support, resulting in low CPU usage on supported devices.&lt;/p&gt;

&lt;p&gt;Chrome 136 Beta added H.265 hardware encoder support, signaling broader adoption. When hardware acceleration is available, H.265 can deliver high-quality video with minimal CPU load, making it attractive for enterprise deployments where devices are newer and more powerful.&lt;/p&gt;

&lt;p&gt;The challenge? &lt;strong&gt;H.265 has limited WebRTC and browser support due to licensing issues.&lt;/strong&gt; Patent licensing fees make it economically complicated for open-source projects and free-tier services. Apple devices support it well, but broad cross-platform support lags behind royalty-free alternatives like VP8, VP9, and AV1.&lt;/p&gt;

&lt;p&gt;For enterprise use cases where all participants are on managed devices with H.265 support, it's a viable option. For general-purpose web applications reaching diverse audiences, VP9 or VP8 remains safer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Codec Selection Decision Framework
&lt;/h3&gt;

&lt;p&gt;Here's how to choose:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Codec&lt;/th&gt;
&lt;th&gt;When to Use&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;th&gt;Limitations&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AV1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Bandwidth-constrained environments, modern hardware with acceleration&lt;/td&gt;
&lt;td&gt;Mobile networks, low-bandwidth scenarios, screen sharing with text&lt;/td&gt;
&lt;td&gt;High CPU usage without hardware support; encoding 5-10× slower than VP9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;VP9&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Group calls, webinars, broadcasts requiring SVC&lt;/td&gt;
&lt;td&gt;Large meetings (10+ participants), live streaming to multiple bitrates&lt;/td&gt;
&lt;td&gt;Slightly higher bandwidth than AV1; less hardware support than H.264&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;H.264&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Maximum compatibility, legacy device support&lt;/td&gt;
&lt;td&gt;Public-facing applications, broad audience reach&lt;/td&gt;
&lt;td&gt;Larger file sizes; older compression technology&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;H.265&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Enterprise deployments with known hardware, low CPU budget&lt;/td&gt;
&lt;td&gt;Managed corporate environments, Apple ecosystem&lt;/td&gt;
&lt;td&gt;Limited browser support due to licensing; not universal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The reality for 2025:&lt;/strong&gt; VP8 and H.264 remain the workhorses for most WebRTC services. VP9 is the go-to for SVC use cases. AV1 is being adopted gradually as hardware support expands. H.265 serves niche enterprise scenarios.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdrh7jil8fbw8cq8fb8oo.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fdrh7jil8fbw8cq8fb8oo.png" alt="Infographic comparison table showing AV1 vs VP9 vs H.264 vs H.265 codecs" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Infrastructure Implications: Bandwidth Economics
&lt;/h3&gt;

&lt;p&gt;From an infrastructure operator's perspective, codec evolution directly impacts bandwidth costs and relay performance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AV1 adoption means 30-50% bandwidth savings&lt;/strong&gt; when it reaches scale. For a TURN relay provider handling petabytes of traffic monthly, that translates to significant cost reduction—potentially millions of dollars annually at large scale. But the transition won't happen overnight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The CPU vs bandwidth trade-off is real.&lt;/strong&gt; Operators must decide whether to push encoding to clients (saving relay server CPU but requiring capable client devices) or handle transcoding server-side (consuming server CPU but supporting any client). This affects hardware procurement, power consumption, and operational costs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Codec negotiation complexity increases.&lt;/strong&gt; Supporting multiple codecs means relay infrastructure must handle fallback scenarios gracefully. When a VP9-capable sender connects to an H.264-only recipient, who transcodes? Where does it happen? These architectural decisions cascade through infrastructure design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Relay performance varies by codec.&lt;/strong&gt; Some codecs handle packet loss better than others. AV1's advanced error resilience means it degrades more gracefully when network conditions deteriorate. Infrastructure operators can optimize retry logic and forward error correction based on which codecs are in use.&lt;/p&gt;

&lt;p&gt;The long-term outlook is clear: gradual AV1 adoption through 2025-2026, with VP9 and H.264 maintaining significant market share for years. Infrastructure must support all of them simultaneously, optimizing for the codecs that see the most traffic while preparing for the shift toward next-generation compression.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trend 4 — IoT &amp;amp; Edge Computing: 18 Billion Devices by Year-End
&lt;/h2&gt;

&lt;p&gt;The Internet of Things is exploding, and WebRTC is becoming the communication protocol of choice for real-time IoT applications. By the end of 2025, an estimated 18 billion IoT devices will be online worldwide, generating a staggering 79.4 zettabytes of data according to IDC.&lt;/p&gt;

&lt;p&gt;Most people associate WebRTC with video calls, but IoT represents a fundamentally different use case—and one that's growing faster than anyone predicted.&lt;/p&gt;

&lt;h3&gt;
  
  
  IoT Device Explosion
&lt;/h3&gt;

&lt;p&gt;The types of devices adopting WebRTC might surprise you. We're not just talking about smart displays or video doorbells (though those are significant). The technology is spreading to smoke detectors, thermostats, industrial sensors, and even agricultural equipment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Smart cameras and video doorbells&lt;/strong&gt; are the most visible examples. Brands like Ring, Nest, and Arlo use WebRTC to stream real-time video from cameras to smartphones without requiring proprietary apps or cloud relay services (though many still use cloud relay for broader compatibility).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Home automation devices&lt;/strong&gt; are integrating WebRTC for remote monitoring and control. A thermostat that can stream live video of the room it's in. A smoke detector that can establish a video call to emergency services automatically when triggered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Industrial IoT&lt;/strong&gt; is where things get interesting. Factory sensors that stream real-time telemetry and video to remote monitoring centers. Construction site cameras that provide live feeds to project managers without on-site IT infrastructure. Agricultural drones that transmit real-time video during automated inspections.&lt;/p&gt;

&lt;p&gt;The common thread? These devices need real-time communication without proprietary apps, cloud dependency, or complex setup. WebRTC provides exactly that—standardized, peer-to-peer (or relay-assisted) communication that works across platforms.&lt;/p&gt;

&lt;h3&gt;
  
  
  WebRTC in IoT
&lt;/h3&gt;

&lt;p&gt;In 2024, AWS released a WebRTC SDK for Kinesis Video Streams specifically to accelerate smart camera integrations. This makes it dramatically easier for device manufacturers to add WebRTC support without building the entire stack from scratch.&lt;/p&gt;

&lt;p&gt;The value proposition is compelling: devices communicate using the same protocol that's already in every web browser. No need for users to install native apps. No need for device manufacturers to maintain separate app codebases for iOS and Android. Just point a browser at a URL, and you're connected to the device.&lt;/p&gt;

&lt;p&gt;Edge computing integration is the force multiplier. Instead of sending raw sensor data to the cloud for processing (which consumes bandwidth and adds latency), devices process data locally at the edge. Then they send only the relevant insights or compressed summaries over WebRTC.&lt;/p&gt;

&lt;p&gt;Consider a security camera with edge AI. It processes video locally to detect motion or recognize faces. When something interesting happens, it establishes a WebRTC connection to send a real-time alert with the relevant video clip. The bulk of the video never leaves the device—only the important moments get transmitted.&lt;/p&gt;

&lt;p&gt;This architecture is more privacy-preserving (raw video doesn't go to the cloud), more bandwidth-efficient (only alerts and clips are sent), and more responsive (detection happens locally without round-trip latency).&lt;/p&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fey2zy43j867fl4lbx37a.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fey2zy43j867fl4lbx37a.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Infrastructure Implications: TURN for IoT
&lt;/h3&gt;

&lt;p&gt;Here's the infrastructure challenge that IoT creates: &lt;strong&gt;many IoT devices sit behind carrier-grade NAT (CGN) or symmetric NAT, making direct peer-to-peer WebRTC connections impossible&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In residential broadband, users typically get a public IP address (or at least a NAT-friendly configuration). IoT devices often connect via cellular networks where CGN is universal. An LTE-connected security camera might have an internal IP like 100.64.0.5—completely unreachable from the public internet.&lt;/p&gt;

&lt;p&gt;The solution? Always-on TURN relay. Unlike typical WebRTC video calls where TURN is a fallback (needed 15-20% of the time), IoT devices behind CGN &lt;strong&gt;require TURN 100% of the time&lt;/strong&gt;. There is no peer-to-peer fallback—the relay is mandatory.&lt;/p&gt;

&lt;p&gt;This changes cost modeling fundamentally. If you're deploying 1,000 IoT cameras, you're not planning for 150-200 to use TURN relay. You're planning for all 1,000 to use relay, all the time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scaling economics shift accordingly.&lt;/strong&gt; 18 billion IoT devices by end of 2025 means exponential TURN relay demand. Even if only 1% of those devices use WebRTC for video streaming, that's 180 million devices requiring always-on relay infrastructure. The bandwidth and server capacity implications are massive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regional distribution becomes critical.&lt;/strong&gt; A smart camera in Tokyo shouldn't relay through a TURN server in Virginia. The latency would make real-time monitoring unusable. IoT deployments need geographically distributed TURN infrastructure—APAC, EMEA, North America, Latin America—to provide acceptable latency for global device fleets.&lt;/p&gt;

&lt;p&gt;APAC is seeing the fastest growth in IoT adoption, driven by rapid digitalization in India, Southeast Asia, and expanding 5G networks in China and South Korea. Infrastructure operators without strong APAC presence will struggle to serve this market effectively.&lt;/p&gt;

&lt;p&gt;Metered's 31+ regions across 5 continents provide the geographic coverage IoT deployments need. When a manufacturer ships cameras to 20 countries, they need relay infrastructure in all 20 countries—not a single region that forces all traffic through intercontinental backhaul.&lt;/p&gt;

&lt;p&gt;The opportunity is enormous, but so are the infrastructure demands. IoT isn't just another WebRTC use case—it's a category that dwarfs traditional video conferencing in scale and requires fundamentally different architectural assumptions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trend 5 — AR/VR/XR: Immersive Experiences Go Mainstream
&lt;/h2&gt;

&lt;p&gt;Augmented reality, virtual reality, and extended reality (collectively XR) are transitioning from experimental novelty to practical mainstream applications in 2025. WebRTC is the invisible infrastructure making it possible.&lt;/p&gt;

&lt;h3&gt;
  
  
  XR Market Maturity in 2025
&lt;/h3&gt;

&lt;p&gt;The XR market in 2025 is defined by three factors: the mainstream rise of smart glasses, deeper AI integration, and rapid improvements in display technology.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Smart glasses are going consumer.&lt;/strong&gt; Meta's Ray-Ban Smart Glasses have signaled growing demand for stylish, functional wearables that blend digital and physical worlds. These aren't the bulky headsets of previous generations—they're glasses that look relatively normal while adding computational layers to what you see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI is making XR more intuitive.&lt;/strong&gt; Real-time object recognition allows glasses to identify objects and provide contextual information. Gesture control eliminates the need for handheld controllers. Generative content means XR environments can adapt dynamically based on what users do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5G-Advanced&lt;/strong&gt; is rolling out in 2025, addressing the latency and bandwidth bottlenecks that previously limited XR applications. Lower latency (sub-10ms in ideal conditions) and more reliable connections make it feasible to stream high-fidelity XR content without requiring powerful local hardware.&lt;/p&gt;

&lt;p&gt;The convergence of these trends is making XR practical for real use cases: virtual collaboration spaces where distributed teams feel like they're in the same room, immersive training simulations for medical and industrial applications, and entertainment experiences that blend physical and digital worlds.&lt;/p&gt;

&lt;h3&gt;
  
  
  WebRTC's Role in the Metaverse
&lt;/h3&gt;

&lt;p&gt;Here's something critical that often gets overlooked: &lt;strong&gt;WebRTC is currently the only option for transmitting real-time video directly from an AR/VR device to a web browser without requiring plugins or native applications&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Think about the implications. A doctor wearing AR glasses during surgery can stream their point-of-view to a specialist consultant on the other side of the world, who views it in a standard web browser. No app installation required, no complex setup—just a WebRTC connection providing real-time, low-latency video.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-party VR experiences&lt;/strong&gt; depend on the lowest possible latency to maintain immersion. When you're in a virtual meeting room with colleagues represented as avatars, every millisecond of delay breaks the sense of presence. Voice needs to be synchronized with lip movements and gestures. If someone reaches to shake your (virtual) hand, the delay between their action and your perception can't exceed 50ms or the illusion shatters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-platform communication&lt;/strong&gt; is where WebRTC becomes indispensable. Apple Vision Pro users need to communicate with Meta Quest users, who need to communicate with people on flat screens. WebRTC provides the standardized protocol that makes cross-platform XR collaboration possible without each vendor implementing proprietary systems.&lt;/p&gt;

&lt;h3&gt;
  
  
  Spatial Audio &amp;amp; Advanced Technologies
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;6-DOF (six degrees of freedom) audio rendering&lt;/strong&gt; lets listeners move freely in a virtual environment—forward, backward, up, down, left, right—and audio positioning stays consistent with their perspective. When you walk around a virtual speaker, the sound appears to come from the correct direction relative to your position.&lt;/p&gt;

&lt;p&gt;This is essential for VR. Without spatial audio, virtual environments feel flat and unconvincing. With it, presence and immersion skyrocket. Dolby has been using WebRTC to improve spatial audio quality, paying particular attention to overlapping speech, laughter, and other aspects of natural communication that previous systems struggled with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Volumetric video&lt;/strong&gt; captures people in three dimensions, allowing you to see them from any angle in VR. Instead of a flat video screen floating in virtual space, you see a 3D representation of the person that you can walk around. This is bandwidth-intensive—volumetric video can require 10-50× more bandwidth than traditional 2D video—but the immersion improvement is transformative.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avatar mirroring&lt;/strong&gt; uses computer vision to track facial expressions and body language, translating them to virtual avatars in real-time. When you smile, your avatar smiles. When you gesture, your avatar gestures. This maintains non-verbal communication cues that are crucial for natural interaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Infrastructure Implications: Ultra-Low Latency Requirements
&lt;/h3&gt;

&lt;p&gt;From an infrastructure perspective, AR/VR applications impose some of the strictest requirements in all of WebRTC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency budgets are brutal.&lt;/strong&gt; For truly immersive experiences, motion-to-photon latency (the time between head movement and updated visual display) must be under 20ms to prevent motion sickness. Audio-visual synchronization must stay within 50ms to avoid perceptible mismatch. End-to-end network latency needs to be under 50ms for multi-party VR to feel natural.&lt;/p&gt;

&lt;p&gt;These aren't aspirational targets—they're hard requirements. Exceed them and users experience discomfort, nausea, or break the sense of presence that makes XR compelling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Volumetric video bandwidth demands&lt;/strong&gt; are enormous. While traditional 1080p video might consume 2-4 Mbps, volumetric video can require 20-100 Mbps depending on quality and compression. TURN relay infrastructure must handle these sustained high-bandwidth streams without introducing additional latency or packet loss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Global relay for cross-continent XR collaboration&lt;/strong&gt; is where private TURN backbones become critical. Imagine a virtual design review with participants in London, Tokyo, and San Francisco. If each participant routes through their nearest TURN server, and those TURN servers relay media over the public internet, latency will be 200-400ms—unacceptable for immersive collaboration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regional distribution matters tremendously.&lt;/strong&gt; An XR application serving users in Southeast Asia needs TURN servers in Singapore, not just Virginia or Frankfurt. The round-trip latency penalty for forcing APAC traffic through Europe or North America makes immersive experiences impossible.&lt;/p&gt;

&lt;p&gt;The opportunity in XR is massive, but the infrastructure demands are unforgiving. Low latency isn't negotiable—it's the difference between an application that works and one that makes users nauseous. Operators who can deliver consistent sub-50ms latency globally will have a decisive advantage as XR goes mainstream.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trend 6 — Security &amp;amp; Privacy: DTLS 1.3 and SFrame E2EE
&lt;/h2&gt;

&lt;p&gt;WebRTC has mandatory encryption on all components—video, audio, and data channels are always encrypted. But in 2025, the security landscape is evolving with protocol updates and new encryption schemes that respond to emerging threats and regulatory requirements.&lt;/p&gt;

&lt;h3&gt;
  
  
  DTLS 1.3 Migration (February 2025)
&lt;/h3&gt;

&lt;p&gt;As of February 2025, the WebRTC ecosystem began migrating to DTLS 1.3. Modern browsers are phasing out older ciphers and requiring applications to implement minimum-version negotiation. DTLS 1.0 and 1.1 are being deprecated.&lt;/p&gt;

&lt;p&gt;Why does this matter? DTLS (Datagram Transport Layer Security) is the protocol that encrypts WebRTC data channels. The upgrade to 1.3 brings stronger cryptographic primitives, improved performance (reduced handshake round-trips), and removes legacy ciphers that have known vulnerabilities.&lt;/p&gt;

&lt;p&gt;For developers, this means updating WebRTC implementations to support DTLS 1.3. For end users, it means stronger security by default with no action required.&lt;/p&gt;

&lt;h3&gt;
  
  
  SFrame End-to-End Encryption
&lt;/h3&gt;

&lt;p&gt;SFrame is being standardized through the IETF and major WebRTC platforms are adopting it for end-to-end encryption in group calls. Here's what makes it significant.&lt;/p&gt;

&lt;p&gt;Traditional WebRTC encryption (DTLS and SRTP) protects media in transit between peers, but in server-mediated scenarios—like video conferences using Selective Forwarding Units (SFUs)—the server can decrypt media to perform routing and optimization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SFrame adds end-to-end encryption that prevents media from being decrypted even on intermediary servers.&lt;/strong&gt; The SFU can still forward packets efficiently, but it can't inspect or modify the actual media content. Only the intended recipients can decrypt the audio and video.&lt;/p&gt;

&lt;p&gt;This is critical for high-security applications: healthcare consultations handling patient data, legal discussions covered by attorney-client privilege, corporate board meetings discussing sensitive strategy. SFrame is recommended for any application where confidentiality requirements extend beyond basic transport security.&lt;/p&gt;

&lt;h3&gt;
  
  
  Forward Secrecy &amp;amp; Session Keys
&lt;/h3&gt;

&lt;p&gt;One of WebRTC's standout security features is &lt;strong&gt;forward secrecy&lt;/strong&gt;—a fresh encryption key is generated for every session. This means that even if current keys are compromised, past communications remain secure because they were encrypted with different, now-deleted keys.&lt;/p&gt;

&lt;p&gt;DTLS handles encryption for data streams, SRTP (Secure Real-time Transport Protocol) handles encryption for media streams. Both generate ephemeral keys per session, ensuring that a breach today doesn't expose yesterday's conversations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compliance &amp;amp; Privacy
&lt;/h3&gt;

&lt;p&gt;Security in 2025 is increasingly driven by regulatory compliance, not just best practices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GDPR mandates encryption of personal data in transit&lt;/strong&gt;, making WebRTC's mandatory encryption a baseline requirement for any application serving European users. Audio and video of identifiable individuals are considered personal data under GDPR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HIPAA and SOC2 compliance&lt;/strong&gt; require end-to-end encryption for telehealth and financial services. SFrame E2EE becomes necessary, not optional, for applications in these regulated industries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebRTC IP leak&lt;/strong&gt; remains a privacy concern. Some browsers may inadvertently expose a user's real IP address through WebRTC even when using VPNs or anonymization tools. This can compromise user privacy, reveal geolocation, or leak personally identifiable information. Privacy-conscious applications need to implement protections against this.&lt;/p&gt;

&lt;p&gt;The signaling channel—the mechanism that sets up WebRTC connections—should always use TLS (HTTPS or WSS) to prevent man-in-the-middle attacks and protect session metadata during connection setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Infrastructure Implications: Security vs Observability
&lt;/h3&gt;

&lt;p&gt;From a relay operator's perspective, E2EE creates a fundamental tension: &lt;strong&gt;security requirements vs operational visibility&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When media is end-to-end encrypted with SFrame, relay servers &lt;strong&gt;cannot inspect the content&lt;/strong&gt;. This is the point—it protects privacy and meets compliance requirements. But it also means operators lose the ability to perform quality diagnostics, detect codec issues, or troubleshoot stream problems by examining media content.&lt;/p&gt;

&lt;p&gt;Traditional WebRTC troubleshooting involves analyzing RTCP reports, packet loss patterns, and sometimes inspecting frames to identify encoding problems. With E2EE, you can see packet-level metadata but not the content itself. Debugging becomes harder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DTLS 1.3 support is mandatory&lt;/strong&gt; for modern WebRTC infrastructure. Relay servers and TURN servers must upgrade to handle the new protocol version. Most operators have already completed this migration, but it's a reminder that security standards evolve and infrastructure must evolve with them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forward secrecy per-session keys&lt;/strong&gt; mean there's no long-lived credential to cache or reuse. Each connection negotiates fresh keys, which adds a small computational overhead but provides the security guarantee that key compromise is limited to the current session only.&lt;/p&gt;

&lt;p&gt;The balance is tricky: operators must provide strong security to meet compliance requirements and user expectations, while maintaining enough operational visibility to diagnose problems when they occur. The trend is clear—security and privacy are non-negotiable, and infrastructure must adapt to support them even when it makes operations more complex.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trend 7 — Market Growth: $247.7 Billion Expansion
&lt;/h2&gt;

&lt;p&gt;The WebRTC market isn't just growing—it's accelerating. Multiple research firms project extraordinary growth through 2033, driven by remote work normalization, telehealth adoption, IoT expansion, and the trends we've already discussed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Market Size Projections (2025-2033)
&lt;/h3&gt;

&lt;p&gt;Different research firms use different methodologies, which explains variance in estimates. But they all agree on one thing: growth is explosive.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.prnewswire.com/news-releases/webrtc-market-to-grow-by-usd-247-7-billion-2025-2029-rising-demand-for-easy-to-use-rtc-solutions-boosting-growth-report-on-ais-impact---technavio-302365252.html" rel="noopener noreferrer"&gt;Technavio&lt;/a&gt; projects the market will grow by USD 247.7 billion from 2025 to 2029, expanding at a CAGR of 62.6%. This is one of the highest growth rates in enterprise software.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.fortunebusinessinsights.com/webrtc-market-109729" rel="noopener noreferrer"&gt;Fortune Business Insights&lt;/a&gt; estimates the market at $9.56 billion in 2025, growing to $94.07 billion by 2032—a CAGR of 38.6%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IMARC Group&lt;/strong&gt; sizes the market at $11.6 billion in 2024, reaching $127.8 billion by 2033 with a CAGR of 30.3%.&lt;/p&gt;

&lt;p&gt;The variance comes from how each firm defines "the WebRTC market." Some include only infrastructure and relay services. Others include CPaaS platforms, application development, and related services. Still others account for the entire value chain including devices, bandwidth, and support.&lt;/p&gt;

&lt;p&gt;Regardless of which estimate you trust, the directional message is unmistakable: &lt;strong&gt;this market is growing faster than almost any other enterprise technology category&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Regional Adoption Patterns
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;North America&lt;/strong&gt; holds 37.55% market share as of 2024, making it the current leader. Mature markets, high broadband penetration, and early adoption of remote work tools have driven WebRTC usage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;APAC&lt;/strong&gt; is showing the fastest growth rate, fueled by rapid digitalization in India and Southeast Asia, expanding 5G networks in China and South Korea, and large populations of mobile-first users who leapfrog traditional desktop infrastructure.&lt;/p&gt;

&lt;p&gt;The APAC opportunity is enormous but requires region-specific infrastructure. A WebRTC platform serving users in Jakarta, Manila, and Hanoi needs relay infrastructure in Southeast Asia—not just Tokyo or Singapore. Latency to users in Indonesia from a Singapore TURN server might be acceptable, but latency from Virginia is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EMEA&lt;/strong&gt; shows steady growth with GDPR compliance driving demand for secure, privacy-preserving solutions. European enterprises prioritize data residency and encryption, making region pinning and E2EE capabilities differentiators in this market.&lt;/p&gt;

&lt;h3&gt;
  
  
  Industry Vertical Adoption
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Telehealth&lt;/strong&gt; has seen explosive growth. 54% of Americans had experienced a telehealth visit by 2024, and telehealth visits surged 38 times from pre-pandemic levels. While some expected a decline as pandemic restrictions eased, the convenience proved sticky—up to 30% of U.S. consultations are expected to remain virtual by 2026.&lt;/p&gt;

&lt;p&gt;WebRTC is the technical foundation enabling browser-based telehealth. Patients join from a web browser without installing apps. Providers can conduct HIPAA-compliant video consultations without complex IT infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enterprise collaboration&lt;/strong&gt; has normalized remote and hybrid work. The "return to office" trend never fully materialized at many companies. WebRTC powers the video conferencing and screen sharing tools that make distributed teams functional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SMEs&lt;/strong&gt; are adopting WebRTC solutions because of cost-effectiveness and scalability. Small businesses with geographically dispersed teams can't afford dedicated IT infrastructure, but they can use cloud-based WebRTC platforms that scale automatically and bill by usage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Education&lt;/strong&gt; has embraced virtual classrooms, breakout rooms, and screen sharing. While in-person instruction has resumed, hybrid and fully remote learning models remain common. WebRTC enables interactive educational experiences that aren't possible with one-way video broadcast.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhtast71csgtvo64kl3rq.png" class="article-body-image-wrapper"&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.amazonaws.com%2Fuploads%2Farticles%2Fhtast71csgtvo64kl3rq.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloud Migration &amp;amp; Platform Consolidation
&lt;/h3&gt;

&lt;p&gt;There's a clear shift from on-premise WebRTC infrastructure to cloud-based platforms. Organizations that previously ran &lt;a href="https://www.metered.ca/blog/coturn/" rel="noopener noreferrer"&gt;self-hosted coturn&lt;/a&gt; servers are migrating to managed TURN services to reduce operational burden and improve reliability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;All-in-one CPaaS platforms&lt;/strong&gt; are gaining traction. Instead of stitching together separate services for TURN relay, signaling, recording, and analytics, companies are consolidating on platforms that bundle these capabilities with predictable pricing and unified support.&lt;/p&gt;

&lt;p&gt;The advantage of managed services is operational: no need to patch servers at 2 AM, no capacity planning guesswork, no multi-region deployment projects. The infrastructure scales automatically and bills by usage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-hosted coturn&lt;/strong&gt; remains popular for companies with specific compliance requirements or very large scale where dedicated infrastructure is cost-effective. But the median use case is shifting toward managed services.&lt;/p&gt;

&lt;h3&gt;
  
  
  Infrastructure Implications: Scaling for Exponential Growth
&lt;/h3&gt;

&lt;p&gt;From an infrastructure operator's perspective, 62% CAGR creates massive scaling challenges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Technical scaling:&lt;/strong&gt; If traffic doubles year-over-year, infrastructure must more than double (to maintain headroom for spikes). This means continuous capacity planning, hardware procurement cycles, and network expansion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost scaling:&lt;/strong&gt; While revenue should grow with traffic, infrastructure costs aren't perfectly linear. At certain thresholds, you need bigger servers, additional regions, more robust network connectivity. Managing cost-per-GB as scale increases requires constant optimization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Geographic expansion:&lt;/strong&gt; Multi-region deployment is no longer optional—it's becoming the baseline expectation. Customers deploying globally expect relay infrastructure in APAC, EMEA, and the Americas at minimum.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TURN relay demand growing exponentially:&lt;/strong&gt; As IoT adoption accelerates (where TURN is required 100% of the time, not 15-20%), relay traffic will grow faster than total WebRTC adoption. This changes infrastructure mix—more relay capacity needed relative to signaling and other services.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TCO advantage of managed TURN:&lt;/strong&gt; A team running self-hosted coturn spends 15-20 hours per month on maintenance, monitoring, and troubleshooting. At $150-200/hour loaded engineer cost, that's $2,700-4,000 per month in opportunity cost—often more than a managed service would cost, and without the reliability, global distribution, or 24/7 support.&lt;/p&gt;

&lt;p&gt;The market is expanding faster than most predicted. The infrastructure to support this growth must scale just as aggressively—and operators who can't keep pace will lose market share to those who can.&lt;/p&gt;




&lt;h2&gt;
  
  
  What These Trends Mean for Infrastructure Operators
&lt;/h2&gt;

&lt;p&gt;We've covered seven trends shaping WebRTC in 2025. Now here's the perspective you won't find anywhere else: &lt;strong&gt;what do these trends actually mean for the infrastructure that makes WebRTC work?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No competitor writes about WebRTC from a TURN relay operator's viewpoint. They cover market trends and application features, but not the architectural, economic, and operational implications for the infrastructure layer. That's a blind spot—and a major one.&lt;/p&gt;

&lt;h3&gt;
  
  
  TURN Relay Architecture Implications
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;AI voice agents&lt;/strong&gt; require global relay for sub-300ms latency. When a user in Singapore talks to an AI hosted in US-East, the relay path can't add more than 50-100ms or the interaction feels sluggish. This demands geographically distributed TURN servers with optimized inter-region connectivity.&lt;/p&gt;

&lt;p&gt;It's not enough to have a TURN server in Singapore and another in Virginia. They need to be connected by a &lt;strong&gt;private, high-speed backbone&lt;/strong&gt; that prioritizes latency over cost. Public internet routing can add 100-200ms for transcontinental connections during congestion. Private backbones avoid this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AR/VR applications&lt;/strong&gt; amplify this requirement. Cross-continent immersive collaboration needs sub-50ms network latency. The only way to achieve this reliably is private relay paths between TURN servers optimized for latency and jitter, not just throughput.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IoT deployments&lt;/strong&gt; need always-on relay because devices sit behind carrier-grade NAT. Unlike video calls where TURN is a fallback, IoT requires TURN 100% of the time. This changes capacity planning—you're not sizing for 15-20% fallback traffic, you're sizing for 100% relay load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MoQ adaptation&lt;/strong&gt; means preparing for dual-protocol support. When MoQ matures in 2026+, relay infrastructure will need to handle both traditional WebRTC TURN and MoQ relay entities. The two protocols serve different use cases, so both will coexist rather than one replacing the other.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bandwidth Economics &amp;amp; Codec Impact
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;AV1 adoption&lt;/strong&gt; delivers 30-50% bandwidth savings at scale. For an infrastructure operator handling 10 petabytes of relay traffic per month, that could represent $100,000+ in monthly bandwidth cost reduction (depending on transit pricing). But AV1 adoption is gradual, not overnight, so cost reduction accrues slowly over 2025-2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Codec selection trade-offs&lt;/strong&gt; affect infrastructure load differently. VP9 with SVC reduces bandwidth for group calls but increases CPU load on servers handling the forwarding logic. H.264/H.265 with hardware encoding reduces CPU but may increase bandwidth consumption. Operators must balance server costs (CPU, memory) against transit costs (bandwidth).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Traffic growth of 62% CAGR&lt;/strong&gt; means bandwidth costs grow exponentially if not managed. Optimizing codec usage, upgrading to more efficient codecs as adoption allows, and negotiating volume discounts with transit providers become critical cost management strategies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Egress fees at cloud providers&lt;/strong&gt; can be prohibitive. If you're running TURN infrastructure on AWS, Azure, or GCP, egress (data leaving the cloud provider's network) can cost $0.05-$0.12 per GB. At petabyte scale, that's tens of thousands per month just in egress. Many operators are moving to colocation or bare-metal to eliminate egress fees entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Security &amp;amp; Relay Challenges
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;E2EE prevents relay diagnostics.&lt;/strong&gt; When SFrame encrypts media end-to-end, relay operators can see packet metadata (timing, size, destination) but not content. This makes troubleshooting codec issues, quality problems, or corruption significantly harder.&lt;/p&gt;

&lt;p&gt;Traditional debugging involves inspecting frames to see if corruption occurred during encoding or transmission. With E2EE, you can't inspect frames—you can only infer problems from packet loss patterns and RTCP reports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DTLS 1.3 migration&lt;/strong&gt; requires infrastructure updates. TURN servers must support the new protocol version. Most operators completed this in early 2025, but it's a reminder that security standards evolve continuously and infrastructure must keep up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forward secrecy per-session keys&lt;/strong&gt; mean no credential caching or reuse. Each connection negotiates fresh keys, adding computational overhead. At scale, this impacts CPU usage on TURN servers handling thousands of concurrent connections.&lt;/p&gt;

&lt;p&gt;The balance is tricky: providing strong security to meet compliance and user expectations while maintaining operational visibility to diagnose and resolve issues quickly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Regional Distribution &amp;amp; Data Residency
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;APAC fastest growth&lt;/strong&gt; means infrastructure without strong APAC presence will struggle. A TURN provider with only North America and Europe coverage can't serve the fastest-growing market effectively. Latency from Jakarta to Frankfurt is 150-200ms—unacceptable for real-time applications.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GDPR and data residency&lt;/strong&gt; requirements mean some customers need guarantees that media doesn't leave specific regions. A telehealth provider serving EU patients might require that all relay happens within EU data centers to comply with GDPR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Region pinning&lt;/strong&gt; becomes a differentiator. The ability to force all traffic for a specific customer or use case to relay through specific geographic regions addresses compliance requirements that are non-negotiable in regulated industries.&lt;/p&gt;

&lt;p&gt;Multi-region deployment used to be a "nice to have" for better latency. In 2025, it's becoming a hard requirement for serving global customers and meeting compliance obligations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scaling for Market Growth
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;18 billion IoT devices plus 62% CAGR&lt;/strong&gt; means infrastructure must scale aggressively and continuously. This isn't a one-time capacity addition—it's an ongoing procurement, deployment, and optimization cycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auto-scaling and multi-region failover&lt;/strong&gt; are becoming baseline expectations, not premium features. Customers expect infrastructure to handle traffic spikes without manual intervention and to fail over seamlessly if a region goes down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Managed service advantages&lt;/strong&gt; become more pronounced at scale. Running self-hosted coturn for a small deployment might make sense, but at scale, the operational complexity, multi-region coordination, and 24/7 monitoring requirements favor managed services.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TCO comparison is compelling:&lt;/strong&gt; 15-20 hours per month of senior engineer time spent on TURN infrastructure (monitoring, patching, troubleshooting, scaling) costs $36,000-$50,000 per year in opportunity cost at typical senior engineer salaries. Many companies would save money and reduce risk by offloading this to a managed provider, even at $2,000-5,000/month.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Metered Enables These Trends
&lt;/h3&gt;

&lt;p&gt;Metered's infrastructure was built specifically to address these challenges:&lt;/p&gt;

&lt;p&gt;31+ regions and 100+ PoPs provide the global distribution that AI, IoT, and XR applications require. Users in Tokyo, São Paulo, and Bangalore all connect to local TURN servers with low latency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Private TURN backbone&lt;/strong&gt; delivers the optimized relay paths critical for AI voice agents (&amp;lt;300ms latency requirement) and cross-continent AR/VR collaboration (&amp;lt;50ms latency requirement). Media relayed between continents travels over Metered's dedicated network, not the unpredictable public internet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sub-30ms global latency&lt;/strong&gt; from anywhere in the world enables latency-sensitive applications that would be impossible with higher-latency infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Premium bandwidth&lt;/strong&gt; from local providers with direct peering maintains consistent quality even during network congestion. Settlement-free bandwidth (used by some competitors) degrades when the public internet is congested. Metered's paid bandwidth guarantees quality at all times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Region pinning&lt;/strong&gt; addresses GDPR and data residency requirements by allowing customers to force all relay traffic through specific geographic regions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;99.999% uptime SLA&lt;/strong&gt; provides the reliability that mission-critical applications—telehealth, enterprise collaboration, financial services—demand. Five nines means less than 5 minutes of downtime per year.&lt;/p&gt;

&lt;p&gt;The infrastructure that works for 2026's WebRTC trends isn't the same as what worked in 2020. The requirements have changed fundamentally, and operators who haven't adapted will struggle to serve the emerging use cases driving growth. You can &lt;a href="https://www.metered.ca/turn-server-testing" rel="noopener noreferrer"&gt;test your TURN server&lt;/a&gt; to verify whether your current infrastructure meets these latency and connectivity benchmarks.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion — WebRTC's Transformative Year
&lt;/h2&gt;

&lt;p&gt;2025 is the year WebRTC transitions from niche real-time communication technology to foundational internet infrastructure. AI integration is moving from experimental to production. Media over QUIC is emerging as a scalable broadcast solution. AV1 is beginning its gradual march toward mainstream adoption. IoT devices are adopting WebRTC at unprecedented scale. AR/VR applications are going mainstream. Security standards are strengthening to meet regulatory requirements. And the market is growing at 62% CAGR.&lt;/p&gt;

&lt;p&gt;From an infrastructure operator's perspective, these trends demand robust, globally distributed TURN relay that can deliver sub-300ms latency for AI, sub-50ms latency for AR/VR, always-on relay for billions of IoT devices, and compliance-ready region pinning for regulated industries.&lt;/p&gt;

&lt;p&gt;The workloads are more demanding. The scale is larger. The geographic distribution requirements are stricter. And the cost of failure—whether that's latency making AI conversations unnatural, or downtime breaking telehealth consultations—is higher than ever.&lt;/p&gt;

&lt;p&gt;2026 will bring MoQ production maturity, broader AV1 hardware acceleration, and continued AI integration. The infrastructure requirements will only intensify. Operators who invest in global distribution, low-latency relay paths, and compliance capabilities now will have decisive advantages as these trends accelerate.&lt;/p&gt;

&lt;p&gt;The infrastructure that powers WebRTC in 2026 isn't a commodity—it's a competitive differentiator that determines which applications can exist and which can't. See how Metered's global TURN infrastructure supports these trends with &lt;a href="https://www.metered.ca/stun-turn" rel="noopener noreferrer"&gt;31+ regions and sub-30ms latency&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQs — WebRTC Trends 2026
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What are the biggest WebRTC trends in 2026?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The seven biggest trends are AI and machine learning integration (voice agents, real-time translation), Media over QUIC protocol emergence (combining WebRTC latency with HLS scale), codec evolution (AV1 bandwidth savings), IoT and edge computing convergence (18 billion devices), AR/VR/XR expansion (spatial audio, immersive experiences), security enhancements (DTLS 1.3, SFrame E2EE), and explosive market growth (62% CAGR, $247.7 billion expansion through 2029).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How is AI changing WebRTC?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AI enhances WebRTC with real-time language translation during video calls, machine learning-powered noise suppression that isolates voices from background sounds, video upscaling that improves low-resolution streams dynamically, sentiment analysis for customer service applications, and sign language translation for accessibility. The OpenAI Realtime API's WebRTC integration (announced December 2024) enables developers to build AI voice agents with sub-300ms latency for natural conversations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is Media over QUIC (MoQ)?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;MoQ is a new streaming protocol developed at the IETF by engineers from Google, Meta, Cisco, Akamai, and Cloudflare. It solves streaming's "historical trilemma" by combining sub-second latency (like WebRTC), broadcast scale (like HLS/DASH), and architectural simplicity. Cloudflare launched the first MoQ relay network in 2025 across 330+ cities. MoQ complements WebRTC rather than competing—WebRTC for interactive communication, MoQ for scalable broadcast. Production readiness is expected in 2026+.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is WebRTC secure in 2025?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. WebRTC has mandatory encryption on all components (video, audio, data channels). The ecosystem migrated to DTLS 1.3 in February 2025, providing stronger cryptographic primitives and removing vulnerable legacy ciphers. SFrame end-to-end encryption is being standardized through IETF, preventing media decryption even on intermediary servers. Forward secrecy generates fresh encryption keys per session, ensuring compromised current keys can't decrypt past communications. GDPR, HIPAA, and SOC2 compliance requirements are driving adoption of these enhanced security measures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What industries are adopting WebRTC?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Telehealth saw 54% of Americans use video consultations by 2024 (38× surge from pre-pandemic levels), with 30% expected to remain virtual by 2026. Enterprise collaboration platforms use WebRTC for distributed teams. SMEs adopt WebRTC for cost-effective communication among geographically dispersed teams. IoT devices (smart cameras, video doorbells, industrial sensors) use WebRTC for real-time monitoring. AR/VR applications use WebRTC for cross-platform immersive experiences. Education platforms use WebRTC for virtual classrooms and interactive learning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the WebRTC market size in 2026?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Market size estimates vary by research firm methodology. Technavio projects USD 247.7 billion growth from 2025-2029 (62.6% CAGR). Fortune Business Insights estimates $9.56 billion in 2025 growing to $94.07 billion by 2032 (38.6% CAGR). IMARC Group sizes the market at $11.6 billion in 2024 reaching $127.8 billion by 2033 (30.3% CAGR). All reports agree on explosive growth driven by AI integration, IoT expansion, telehealth adoption, and remote work normalization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What codecs does WebRTC support in 2026?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;WebRTC supports VP8 (universal compatibility), VP9 (only codec with Scalable Video Coding for group calls), H.264 (maximum compatibility across devices), H.265/HEVC (hardware-accelerated efficiency on supported devices, Chrome 136 Beta added support), and AV1 (30-50% bandwidth savings but 5-10× slower encoding without hardware acceleration). In practice, VP8 and H.264 remain the workhorses handling most WebRTC traffic in 2025, with gradual AV1 adoption as hardware support improves.&lt;/p&gt;

&lt;p&gt;For the official WebRTC specification, see the &lt;a href="https://www.w3.org/TR/2025/REC-webrtc-20250313/" rel="noopener noreferrer"&gt;W3C WebRTC Recommendation (2025)&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>webrtc</category>
      <category>programming</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
