<?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>Managed TURN Server: The Complete Guide to TURN-as-a-Service (2026)</title>
      <dc:creator>alakkadshaw</dc:creator>
      <pubDate>Thu, 13 Aug 2026 17:04:37 +0000</pubDate>
      <link>https://dev.to/alakkadshaw/managed-turn-server-the-complete-guide-to-turn-as-a-service-2026-1c5k</link>
      <guid>https://dev.to/alakkadshaw/managed-turn-server-the-complete-guide-to-turn-as-a-service-2026-1c5k</guid>
      <description>&lt;p&gt;A managed TURN server is a hosted NAT-traversal relay run by a provider, so you skip deploying and scaling &lt;a href="//metered.ca/blog/coturn"&gt;coturn&lt;/a&gt; yourself. It relays WebRTC media when a direct peer connection fails behind firewalls or symmetric NATs. You use one when reliability, global regions, and predictable operations matter.&lt;/p&gt;

&lt;p&gt;This guide defines the category and gives you a seven-point framework for choosing a provider.&lt;/p&gt;

&lt;p&gt;TURN sits at the unglamorous center of every real WebRTC product. When your video call works on your laptop but dies on a customer's corporate network, TURN is the missing piece. The question is rarely &lt;em&gt;whether&lt;/em&gt; you need it — it's whether you host it or buy 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%2Fn6o02ck1dgi98bu3d3g1.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%2Fn6o02ck1dgi98bu3d3g1.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; A managed TURN server is TURN-as-a-service: a provider hosts, scales, and secures the relay infrastructure WebRTC needs to cross firewalls. Choose one on seven criteria — regions, ports 80/443 + TURNS, documentation and API, bundled signalling, SLA, support, and data residency. &lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What is a managed TURN server?
&lt;/h2&gt;

&lt;p&gt;A managed TURN server is TURN relay infrastructure that a vendor runs for you, delivered as a paid or free-tier cloud service. You get credentials and an endpoint; the provider owns the servers, the bandwidth, the ports, the TLS certificates, and the uptime.&lt;/p&gt;

&lt;p&gt;TURN stands for Traversal Using Relays around NAT. It is defined in &lt;a href="https://datatracker.ietf.org/doc/html/rfc8656" rel="noopener noreferrer"&gt;IETF RFC 8656&lt;/a&gt;, and it exists because most devices sit behind network address translation that blocks unsolicited inbound traffic.&lt;/p&gt;

&lt;p&gt;Here's the short version of how WebRTC connects two peers. First it tries a direct connection. When that fails, &lt;a href="https://www.metered.ca/tools/openrelay/stun-servers-and-friends" rel="noopener noreferrer"&gt;STUN&lt;/a&gt; helps each peer discover its public address.&lt;/p&gt;

&lt;p&gt;When &lt;em&gt;that&lt;/em&gt; still fails — think symmetric NAT or a locked-down firewall — TURN relays the media through a public server both peers can reach.&lt;/p&gt;

&lt;p&gt;STUN only discovers addresses. TURN carries your actual audio and video, which is why it is the piece that has to be engineered for scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Managed TURN vs raw TURN vs STUN — the three-way distinction worth getting straight:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;STUN&lt;/strong&gt; tells a peer its public IP. It relays nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw (self-hosted) TURN&lt;/strong&gt; is coturn on your own servers. Free software, but you own the ports, certs, patching, scaling, and bandwidth bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed TURN&lt;/strong&gt; is that same relay function bought as a service, with regions, SLAs, and credential APIs handled for you.&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%2Fbuxgy9ioj0v1iek1xvkp.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%2Fbuxgy9ioj0v1iek1xvkp.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A &lt;a href="https://www.metered.ca/stun-turn" rel="noopener noreferrer"&gt;managed TURN server&lt;/a&gt; matters most for the sessions that can't connect directly — a share measured at roughly 8–20% of WebRTC sessions. That share varies by audience: consumer apps on home Wi-Fi fare better; enterprise, healthcare, and mobile-carrier networks fare much worse. When relay fails, the call simply doesn't connect, so TURN is not optional for a serious product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Managed TURN vs self-hosted coturn — which do you actually need?
&lt;/h2&gt;

&lt;p&gt;If you are building a serious product, buy the relay. Self-hosting coturn is always &lt;em&gt;available&lt;/em&gt;, but it is not free, and it does not become the cheaper option as you grow — it becomes the more expensive one.&lt;/p&gt;

&lt;p&gt;Running your own coturn server carries three distinct costs: the hosting bill for every region you deploy to, the bandwidth fees for the media you relay, and the DevOps hours to build, secure, and keep it running.&lt;/p&gt;

&lt;p&gt;The first two are visible on an invoice. The third is the one that decides this.&lt;/p&gt;

&lt;p&gt;The engineering burden is not a fixed startup cost you pay once and then outgrow. &lt;strong&gt;It grows with your own product&lt;/strong&gt; — every sign that things are going well is also more relay work.&lt;/p&gt;

&lt;p&gt;Launch in one region and you have one server to patch. Pick up users on another continent and you need relays near them, plus the routing to send each session to the right one.&lt;/p&gt;

&lt;p&gt;Some of your users will be on locked-down office Wi-Fi or restrictive mobile networks, so you add TURNS on port 443 and inherit certificate rotation with it. And once enough of your traffic depends on relay, a relay outage is an outage of &lt;em&gt;your app&lt;/em&gt; — so somebody on your team is carrying a pager for it at 3am.&lt;/p&gt;

&lt;p&gt;If your product touches payments or health data, the region-pinning and fixed-IP work lands on you as well, because it is your compliance story now, not a vendor's.&lt;/p&gt;

&lt;p&gt;None of that is a one-time build. It is a permanent line item that grows as your product does — which means it arrives fastest exactly when you are busiest shipping the features people came for.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;DATED FACT BLOCK — the self-hosting reality (2026-08-11)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You own the patch treadmill.&lt;/strong&gt; Example: &lt;strong&gt;CVE-2025-69217&lt;/strong&gt; (published Dec 2025) affected coturn &lt;strong&gt;4.6.2r5 through 4.7.0-r4&lt;/strong&gt; — a weak random-number generator let an attacker predict nonces after ~50 unauthenticated allocation requests and spoof authenticated traffic. It's fixed in current releases, but &lt;em&gt;you&lt;/em&gt; have to notice and patch it. (Sources: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-69217" rel="noopener noreferrer"&gt;NVD&lt;/a&gt;, &lt;a href="https://github.com/coturn/coturn/security/advisories/GHSA-fvj6-9jhg-9j84" rel="noopener noreferrer"&gt;coturn GHSA-fvj6-9jhg-9j84&lt;/a&gt;.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You own the ports, the TLS certs, the DDoS exposure, and the bandwidth bill&lt;/strong&gt; on every region you deploy to.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;To be clear about what is and isn't being claimed here: &lt;a href="//metered.ca/blog/coturn"&gt;coturn&lt;/a&gt; is excellent software. We maintain a full guide to deploying it.&lt;/p&gt;

&lt;p&gt;The question isn't whether coturn is good. It's whether running relay infrastructure is your product. For a serious player it almost never is — and every hour spent on certificate rotation and CVE triage is an hour not spent on the thing customers actually pay you for.&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%2F487yj32gf9sdvrym6ubq.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%2F487yj32gf9sdvrym6ubq.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Self-hosting "to save money" is where the savings turn notional. The infrastructure bill you avoided shows up as engineering salary, on-call fatigue, and a security surface you now own — and it arrives on the Friday a CVE lands, not on a schedule you chose.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose a managed TURN provider
&lt;/h2&gt;

&lt;p&gt;Pick a managed TURN provider on seven criteria, in this order: regions, ports, documentation and API, bundled signalling, SLA, support, and data residency. Price is a tiebreaker, not the headline — the cheapest relay that can't traverse your customer's firewall is worthless.&lt;/p&gt;

&lt;p&gt;Here's the checklist in detail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Regions and PoPs.&lt;/strong&gt; Relay latency is dominated by distance. A provider with points of presence near your users adds far less delay than one routing every call through a single continent. Ask how many regions, and whether you can pin traffic to specific ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Ports 80/443 and TURNS/TLS.&lt;/strong&gt; This is the criterion that separates a demo from a product. Restrictive corporate firewalls block UDP and odd ports but allow 443. A TURN server that speaks &lt;strong&gt;TURNS (TURN over TLS) on port 443&lt;/strong&gt; looks like ordinary HTTPS and gets through where a 3478-only server fails. If a provider can't do 443, it can't serve locked-down networks. See our TURN over ports 80 and 443 guide for the deep dive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Documentation, API, and AI-readability.&lt;/strong&gt; This is the criterion that decides how fast you ship. Buying a managed relay should mean one afternoon of integration and then nothing: read the docs, call the credential endpoint, hand the ICE servers to your peer connection, and move on to your actual product. The relay is the provider's job to run from that point forward.&lt;/p&gt;

&lt;p&gt;Ask what the API actually gives you. You want a REST surface that mints short-lived, per-session credentials, scopes them per project, and reports your usage.&lt;/p&gt;

&lt;p&gt;Then ask the 2026 question: &lt;strong&gt;are the docs machine-readable?&lt;/strong&gt; Your coding assistant is writing the integration alongside you, and it can only be as accurate as what it can retrieve. Providers that publish plain-text reference files for LLMs get correct generated code on the first pass; providers whose API sits behind JavaScript-rendered pages get confidently invented endpoints.&lt;/p&gt;

&lt;p&gt;Metered documents 28 REST endpoints and publishes a full set of machine-readable &lt;code&gt;.txt&lt;/code&gt; references at &lt;a href="https://www.metered.ca/docs/build-with-ai/" rel="noopener noreferrer"&gt;metered.ca/docs/build-with-ai&lt;/a&gt;, including a complete TURN reference — so you and your assistant both have the entire surface in front of you from the first line of code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Bundled signalling.&lt;/strong&gt; TURN is only half the job. Every WebRTC app also needs signalling — the exchange that carries SDP offers, answers, and ICE candidates between peers before any media flows. Most dedicated TURN providers sell relay only and leave that half to you, which means a second vendor, a second bill, and a second thing to operate.&lt;/p&gt;

&lt;p&gt;So ask whether signalling is included, and if it is, ask the two follow-ups that decide whether it's real: &lt;strong&gt;is the client open-source&lt;/strong&gt;, and &lt;strong&gt;are TURN credentials injected automatically at connect time&lt;/strong&gt; — or are you back to embedding them yourself? Metered's paid TURN plans carry a bundled Realtime Messaging allowance that handles SDP/ICE exchange, presence, and pub/sub, with your TURN credentials auto-injected into the connection's welcome message. The client is &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;, MIT-licensed and open-source, so nothing about the arrangement locks you in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. SLA.&lt;/strong&gt; A published uptime guarantee (and the credits behind it) tells you whether the provider treats relay as a product or a side feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Support.&lt;/strong&gt; Every so often a user sits behind a network nobody has seen before — an unusual corporate proxy, a carrier-grade NAT, a country-level filter. You want someone on the other end of the line who has seen that network before, at the hour it happens, not a ticket queue measured in business days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Data residency and fixed IPs.&lt;/strong&gt; Regulated workloads (payments, healthcare) often need traffic to stay in a region and to originate from &lt;strong&gt;fixed, allowlistable IPs&lt;/strong&gt;. Anycast networks are fast but can't promise either. If you need GDPR or HIPAA alignment, this criterion can outrank price entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The evaluation matrix — what to ask before you sign
&lt;/h2&gt;

&lt;p&gt;Take this table into every vendor conversation. The middle column is the question to ask; the right column is why the answer decides the purchase. Providers churn their pricing pages constantly, so score the &lt;em&gt;capabilities&lt;/em&gt; — they change far more slowly than the rate card.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Ask the provider&lt;/th&gt;
&lt;th&gt;Why it decides the purchase&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Regions &amp;amp; PoPs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;How many regions, how many edge PoPs, and can I pin a session to one?&lt;/td&gt;
&lt;td&gt;Relay adds a hop; distance is the dominant term in added latency. No nearby PoP means audible delay.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ports 80/443 + TURNS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Do you serve TURNS over TLS on 443 — on the free tier as well as paid?&lt;/td&gt;
&lt;td&gt;3478-only relay fails on exactly the restrictive corporate networks that made you buy TURN.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Docs, API &amp;amp; AI-readability&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Is there a REST API for short-lived per-session credentials — and are the docs published as machine-readable references an LLM can retrieve?&lt;/td&gt;
&lt;td&gt;Decides how fast you ship. Static-only credentials can't be rotated or revoked; unretrievable docs mean your coding assistant invents endpoints.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bundled signalling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Is signalling included? Is the client open-source, and are TURN credentials auto-injected at connect time?&lt;/td&gt;
&lt;td&gt;TURN is half the job. Buying the other half separately means a second vendor, a second bill, and a second thing to operate.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SLA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;What uptime is contractually guaranteed, and what credits back it?&lt;/td&gt;
&lt;td&gt;Distinguishes a relay product from a relay side-feature.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Support&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;24/7 human support, or ticket queues measured in business days?&lt;/td&gt;
&lt;td&gt;A user who can't connect from a network you can't reproduce is a same-day problem.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Data residency &amp;amp; fixed IPs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Can you guarantee in-region traffic and give me fixed, allowlistable IPs?&lt;/td&gt;
&lt;td&gt;Anycast can't promise either. For payments and healthcare this can outrank price entirely.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Scope note:&lt;/strong&gt; this guide covers dedicated TURN — relay bought as a managed service, and coturn self-hosted. &lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  What you actually get
&lt;/h3&gt;

&lt;p&gt;Metered gives you &lt;strong&gt;fixed, allowlistable IPs, region pinning and data residency (GDPR/HIPAA), a standalone TURN product, an **SLA up to 99.999%&lt;/strong&gt;, and &lt;strong&gt;24/7 human support&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;For a payments or healthcare product or anyone that has to route traffic through a specific region from a known IP, that control &lt;em&gt;is&lt;/em&gt; the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Managed TURN in the AI-agent era
&lt;/h2&gt;

&lt;p&gt;Managed TURN matters more, not less, as AI voice and video agents ship. Server-to-server and headless agent connections can't lean on the browser-side direct-path optimizations that consumer calls use, so a much larger share of agent traffic ends up relayed through TURN (InfoQ, 2026).&lt;/p&gt;

&lt;p&gt;That shift is already visible in the market: through 2026, dedicated TURN providers have been repositioning their messaging around AI voice and video agents. The reliability bar for an always-on agent is higher than for a human call.&lt;/p&gt;

&lt;p&gt;For agents you want the same things, turned up: relay near the model and the user, ports 443/TURNS so the agent works from inside restricted networks, and short-lived credentials minted per session. We go deeper in TURN for AI voice agents.&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%2Fbzdmoqpv27t8b7jeu1qp.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%2Fbzdmoqpv27t8b7jeu1qp.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting started with Metered's managed TURN
&lt;/h2&gt;

&lt;p&gt;If you want a managed TURN server with fixed IPs, region pinning, and 24/7 human support behind it, &lt;a href="https://www.metered.ca/stun-turn" rel="noopener noreferrer"&gt;Metered's TURN service&lt;/a&gt; is built for exactly that.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;METERED TURN — capabilities (single reference block, verified against &lt;code&gt;/stun-turn&lt;/code&gt;)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free to start&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open Relay:&lt;/strong&gt; 20 GB/month free TURN — &lt;a href="https://www.metered.ca/tools/openrelay/" rel="noopener noreferrer"&gt;openrelay.metered.ca&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metered TURN product:&lt;/strong&gt; 500 MB free trial, then paid plans — current tiers on &lt;a href="https://www.metered.ca/stun-turn" rel="noopener noreferrer"&gt;the TURN service page&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Platform&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;31+ regions, 100+ edge PoPs&lt;/strong&gt;, sub-30 ms typical latency&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ports 80 and 443&lt;/strong&gt;, &lt;strong&gt;TURNS/TLS&lt;/strong&gt;, TCP + UDP&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fixed IPs, region pinning, auto-geo routing, data residency&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;REST credential API&lt;/strong&gt; (short-lived, per-session credentials) + machine-readable docs for AI coding assistants&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bundled signalling&lt;/strong&gt; — Realtime Messaging allowance on paid plans: SDP/ICE exchange, presence, pub/sub, TURN credentials auto-injected at connect time; MIT-licensed open client&lt;/li&gt;
&lt;li&gt;Up to &lt;strong&gt;99.999% uptime&lt;/strong&gt; (Enterprise), 24/7 support&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;Getting a working relay takes minutes. free turn server is also available via &lt;a href="//openrelayproject.org"&gt;Open Relay project&lt;/a&gt;, then request dynamic credentials from the REST API so every user session gets its own short-lived TURN credentials — no static secret in your client.&lt;/p&gt;

&lt;p&gt;TURN is only half the stack: every WebRTC app also needs signalling. Metered includes managed signalling free with TURN, and the open-source MIT-licensed &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; SDK is the client for it — it connects to Metered's stack and injects your TURN credentials for you. Before you ship, &lt;a href="//metered.ca/turn-server-testing"&gt;test your TURN server&lt;/a&gt; to confirm relay candidates actually appear.&lt;/p&gt;

&lt;p&gt;Teams building in security, payments, and healthcare choose managed TURN for the parts a feature list never captures: a fixed IP to hand their own security reviewers, traffic that provably stays in-region, and a human to call when a user's network misbehaves.&lt;/p&gt;

&lt;p&gt;[IMAGE_PLACEHOLDER: getting-started]&lt;/p&gt;

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

&lt;p&gt;[IMAGE_PLACEHOLDER: faq]&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a managed TURN server?
&lt;/h3&gt;

&lt;p&gt;A managed TURN server is TURN relay infrastructure hosted by a provider and consumed as a service. It relays WebRTC audio and video when peers can't connect directly through firewalls or symmetric NATs. You get an endpoint and credentials; the provider owns the servers, bandwidth, ports, TLS certificates, scaling, and uptime, so you never deploy or patch coturn yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Managed TURN vs self-hosted coturn — which should I choose?
&lt;/h3&gt;

&lt;p&gt;Compare all three costs, not just the bandwidth line. Self-hosting coturn means paying for hosting in every region you serve, paying the relayed-media bandwidth bill anyway, and funding the DevOps hours to configure TLS on 443, rotate certificates, track security advisories, run an on-call rotation, and scale relays. Managed TURN collapses all of that into one predictable number.&lt;/p&gt;

&lt;p&gt;The engineering half doesn't shrink as you grow. More regions, more users on restrictive networks, and a higher uptime bar each add work — so for a serious product, buying is cheaper where it counts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a TURN server, or is STUN enough?
&lt;/h3&gt;

&lt;p&gt;Try STUN first — it's nearly free and handles most connections. But a meaningful minority of WebRTC sessions can't connect directly, and for those you need TURN to relay the media; the share is higher on enterprise, healthcare, and mobile-carrier networks. Without TURN, those calls simply fail, so any production app needs a TURN server available.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which managed TURN provider is best?
&lt;/h3&gt;

&lt;p&gt;It depends on your constraints, so score every candidate on the seven criteria above and treat price as the tiebreaker. If you need fixed allowlistable IPs, region pinning, data residency, a published SLA, a predictable flat bill, and 24/7 human support, that is precisely what Metered is built for. A relay that can't traverse your customer's firewall is not the cheaper option, whatever its rate card says.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a managed TURN server work on ports 80 and 443?
&lt;/h3&gt;

&lt;p&gt;Yes, and it's essential. Restrictive firewalls block UDP and non-standard ports but allow 443, so a managed TURN server that speaks TURNS (TURN over TLS) on port 443 looks like ordinary HTTPS traffic and traverses locked-down corporate networks. Metered ships 80/443 plus TURNS; providers that only offer port 3478 will fail on the exact networks where you most need relay.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is there a free tier for a managed TURN server?
&lt;/h3&gt;

&lt;p&gt;Yes. Open Relay gives you 20 GB/month of free TURN bandwidth, on ports 80 and 443 with TURNS — no port restrictions. Free tiers are available; move to a plan with an SLA before production. Start with &lt;a href="https://www.metered.ca/tools/openrelay/" rel="noopener noreferrer"&gt;Open Relay's free tier&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;A managed TURN server buys you out of the least rewarding part of WebRTC: running relay infrastructure. If you are building a serious product, that is the right trade — the engineering cost of self-hosting grows with your own product, arriving exactly when you are busiest shipping the features people came for.&lt;/p&gt;

&lt;p&gt;Choose on regions, ports 80/443 + TURNS, documentation and API, bundled signalling, SLA, support, and data residency. Price is the tiebreaker, not the decision.&lt;/p&gt;

&lt;p&gt;When you're ready, &lt;a href="https://www.metered.ca/tools/openrelay/" rel="noopener noreferrer"&gt;Use the free turn server on Open Relay&lt;/a&gt;, then 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 fixed IPs, region pinning, and a predictable bill.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About the author:&lt;/strong&gt; this article has been written by James Bordane an Open Source Enthusiast&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>devops</category>
      <category>programming</category>
    </item>
    <item>
      <title>What Is an AI Voice Agent? How Voice Agents Actually Work</title>
      <dc:creator>alakkadshaw</dc:creator>
      <pubDate>Wed, 29 Jul 2026 15:37:50 +0000</pubDate>
      <link>https://dev.to/alakkadshaw/what-is-an-ai-voice-agent-how-voice-agents-actually-work-439k</link>
      <guid>https://dev.to/alakkadshaw/what-is-an-ai-voice-agent-how-voice-agents-actually-work-439k</guid>
      <description>&lt;p&gt;An AI voice agent is software you can talk to. It listens to you speak, reasons about what you said, and talks back in real time — holding a natural spoken conversation instead of marching you through a phone menu.&lt;/p&gt;

&lt;p&gt;That definition is the easy part. Every explainer on the internet will tell you a voice agent is speech-to-text, plus a language model, plus text-to-speech.&lt;/p&gt;

&lt;p&gt;Here is the part almost nobody writes down: &lt;strong&gt;a voice agent is a latency and connectivity problem, not a prompt problem.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The language model is the easy 80%. Wiring three models together is a weekend project. The hard part — the part that decides whether your agent feels human or just goes silent in a customer's office — is the plumbing underneath: how the audio actually travels, how fast, and whether it can connect at all.&lt;/p&gt;

&lt;p&gt;This guide covers the whole stack. You'll get the standard architecture, the honest 2026 trade-offs, a real latency budget with numbers we measured ourselves, and the transport layer that the top-ranking guides leave out entirely.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; An AI voice agent hears you (speech-to-text), thinks (an LLM), and speaks back (text-to-speech), coordinated by an orchestrator that manages turns and interruptions — with every stage streamed to stay under the ~800 ms that keeps a conversation feeling natural. The layer most guides skip is transport: the audio rides WebRTC, and on real-world networks it needs a TURN relay, or the agent connects and goes silent.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What Is an AI Voice Agent?
&lt;/h2&gt;

&lt;p&gt;An AI voice agent is an autonomous, voice-first system that holds a natural spoken conversation, reasons about it in real time with a large language model, and takes action across connected systems — without a human scripting each turn. It combines speech-to-text, an LLM, and text-to-speech, connected over a real-time media transport like WebRTC.&lt;/p&gt;

&lt;p&gt;That last clause is the one you rarely see, and it is the whole reason this article exists.&lt;/p&gt;

&lt;p&gt;The difference between a voice agent and the older systems it replaces is intent. An IVR forces callers down a fixed menu tree — "press 1 for sales." A chatbot handles typed text. A voice agent understands natural spoken language, decides what to do, calls your backend, and answers in speech, per the distinction drawn across &lt;a href="https://aircall.io/blog/what-is-an-ai-voice-agent/" rel="noopener noreferrer"&gt;Aircall's 2026 explainer&lt;/a&gt; and &lt;a href="https://deepgram.com/learn/what-exactly-is-an-ai-voice-agent" rel="noopener noreferrer"&gt;Deepgram's 2026 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%2Fgkhpmwjz5ys5qvjgpl21.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%2Fgkhpmwjz5ys5qvjgpl21.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So a voice agent is a loop, not a black box. It hears, it thinks, it speaks, and something has to conduct all three in the right order, fast enough that you don't notice the seams.&lt;/p&gt;

&lt;p&gt;That conductor is the orchestrator, and it does more than pass data between stages. Let's break the loop down stage by stage.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do Voice Agents Work? The Four Stages
&lt;/h2&gt;

&lt;p&gt;A voice agent runs four real-time stages in a tight loop: speech-to-text transcribes what you say, an LLM decides the reply and calls any tools, text-to-speech speaks it, and an orchestrator manages when a turn ends and when to stop for an interruption. Streaming overlaps the stages so the reply starts before the model has finished thinking.&lt;/p&gt;

&lt;p&gt;Here's each stage and what it's actually responsible for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Speech-to-text (STT)&lt;/strong&gt; turns your audio into words the model can read. In a good agent it runs continuously, emitting partial transcripts as you talk rather than waiting for you to finish.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The LLM&lt;/strong&gt; reads the transcript, decides what to say, and — when needed — calls tools: look up an order, book the appointment, check inventory. This is the "brain," but as you'll see in the latency section, it is rarely the slow part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Text-to-speech (TTS)&lt;/strong&gt; turns the reply back into audio. Streaming TTS starts speaking the first words while the rest of the sentence is still being generated, which is what keeps the pause short.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The orchestrator&lt;/strong&gt; is the unsung hero. It decides when your turn has ended (turn detection), starts the reply, and — critically — stops the agent mid-sentence the instant you interrupt. That interruption behavior is called barge-in, and getting it right is most of what makes an agent feel human rather than robotic.&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%2Fxly69kzgamnxfiev7mlj.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%2Fxly69kzgamnxfiev7mlj.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Notice the theme: everything streams. A voice agent that waits for each stage to fully finish before starting the next one feels broken, because the silence stacks up.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.assemblyai.com/blog/voice-agent-architecture" rel="noopener noreferrer"&gt;AssemblyAI's April 2026 architecture breakdown&lt;/a&gt; puts a number on it: a naive, non-streaming pipeline adds two to four seconds of dead air per turn. Nobody waits four seconds for a "hello."&lt;/p&gt;

&lt;p&gt;That's the standard model. But in 2026 there's a real architectural fork in the road — one model or three?&lt;/p&gt;

&lt;h2&gt;
  
  
  Cascaded Pipeline vs Speech-to-Speech
&lt;/h2&gt;

&lt;p&gt;There are two ways to build the "hear-think-speak" loop, and the choice is a genuine engineering trade-off in 2026. A &lt;strong&gt;cascaded pipeline&lt;/strong&gt; chains three separate models — STT, then LLM, then TTS — with readable text between each step. A &lt;strong&gt;speech-to-speech (S2S)&lt;/strong&gt; model does it in one shot: audio in, audio out, no text in the middle.&lt;/p&gt;

&lt;p&gt;Cascade gives you a text artifact at every step, so you can log it, moderate it, filter it, and route on it. Speech-to-speech often feels more natural and can be faster, but it's harder to debug, more expensive, and less transparent.&lt;/p&gt;

&lt;p&gt;As of April 2026, cascade still dominates production, per &lt;a href="https://deepgram.com/learn/speech-to-speech-vs-cascade-voice-agent-architecture" rel="noopener noreferrer"&gt;Deepgram&lt;/a&gt; and &lt;a href="https://softcery.com/lab/ai-voice-agents-real-time-vs-turn-based-tts-stt-architecture" rel="noopener noreferrer"&gt;Softcery's lab tests&lt;/a&gt;. Here is how the two stack up on the numbers people actually argue about.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Cascaded (STT → LLM → TTS)&lt;/th&gt;
&lt;th&gt;Speech-to-speech (one model)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Time to first audio&lt;/td&gt;
&lt;td&gt;~1.5–3 s in production (Softcery/Deepgram, 2026)&lt;/td&gt;
&lt;td&gt;0.78–2.98 s across models (Softcery, Apr 2026)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per minute&lt;/td&gt;
&lt;td&gt;~$0.05–0.15 (Softcery, Apr 2026)&lt;/td&gt;
&lt;td&gt;~$0.15–0.60 for premium realtime (Softcery, Apr 2026)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transparency&lt;/td&gt;
&lt;td&gt;High — text at every stage to log and filter&lt;/td&gt;
&lt;td&gt;Low — audio in, audio out, harder to audit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debuggability&lt;/td&gt;
&lt;td&gt;Easy — inspect the transcript and the reply&lt;/td&gt;
&lt;td&gt;Hard — no intermediate text artifact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Transactional, regulated, tool-heavy agents&lt;/td&gt;
&lt;td&gt;Natural-feeling, latency-sensitive chat&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cost gap is not a rounding error. Softcery's April 2026 measurements show a spread of up to &lt;strong&gt;182×&lt;/strong&gt; between the cheapest cascaded stack and premium realtime speech-to-speech models — the difference between a fraction of a cent and roughly thirty cents a minute.&lt;/p&gt;

&lt;p&gt;Cascade runs about $0.05–$0.15 per minute and produces a clean text log at every step; premium speech-to-speech models run roughly $0.15–$0.60 per minute and feel more natural but are far harder to audit. Their time-to-first-audio ranges from about 0.78 seconds on the fastest model to nearly 3 seconds on others, versus roughly 1.5–3 seconds end-to-end for a well-built cascade.&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%2Fmlwaii9k6bce42ww41j4.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%2Fmlwaii9k6bce42ww41j4.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's what most teams actually do: a hybrid. Use a speech-to-speech model for the natural-feeling opener, then fall back to a cascaded pipeline for the transactional turns where you need a text log and tool calls. &lt;/p&gt;

&lt;p&gt;Now, every source above measures the models. None of them measures the wire the audio travels on. That's the layer we're built to explain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Layer Most Guides Skip: How the Audio Actually Travels
&lt;/h2&gt;

&lt;p&gt;Here is the gap. The best voice-agent architecture guides — &lt;a href="https://www.assemblyai.com/blog/voice-agent-architecture" rel="noopener noreferrer"&gt;AssemblyAI's&lt;/a&gt; (April 2026) and &lt;a href="https://deepgram.com/learn/what-exactly-is-an-ai-voice-agent" rel="noopener noreferrer"&gt;Deepgram's&lt;/a&gt; (2026) among them — walk through STT, LLM, and TTS in detail and then stop. Deepgram names WebRTC as a transport and moves on. None of them explains the layer underneath: how the audio actually gets from a user's microphone to your agent and back.&lt;/p&gt;

&lt;p&gt;So let's fill it in.&lt;/p&gt;

&lt;p&gt;Audio has to ride something. There are three real choices for moving real-time voice, and they are not interchangeable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebRTC&lt;/strong&gt; is the transport built for exactly this job. It runs over UDP, was designed for low-latency real-time media, handles packet loss gracefully, and includes the machinery to punch through firewalls. The emerging consensus for production voice agents is WebRTC over WebSockets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebSockets&lt;/strong&gt; run over TCP. They're perfect for control messages, transcripts, and prototypes — but TCP's head-of-line blocking means one late packet stalls everything behind it, which is exactly the wrong property for live audio. Fine for a demo; not what production settles on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SIP&lt;/strong&gt; is the telephony path. If your agent answers actual phone calls over the PSTN, SIP is in the picture — it's a live transport decision for phone-based agents, as &lt;a href="https://relinns.com/blogs/webrtc-vs-sip-for-ai-voice-agents" rel="noopener noreferrer"&gt;relinns' 2026 comparison&lt;/a&gt; lays out. For a web or app-based agent, WebRTC is the default.&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%2Fxxrvlciwq2sob67dgrux.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%2Fxxrvlciwq2sob67dgrux.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So for the agents most people are building — web and app — the answer is WebRTC. But choosing WebRTC is where the connectivity problem begins, not where it ends.&lt;/p&gt;

&lt;p&gt;Because WebRTC prefers a direct UDP path, and on a lot of real-world networks, that direct path simply doesn't exist. Hold that thought — we'll get to why in two sections. First, let's put real numbers on "fast enough."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Latency Budget (With Our Measured Numbers)
&lt;/h2&gt;

&lt;p&gt;Here's the target: keep the end-to-end response under about 800 milliseconds at the 95th percentile, from the moment you stop talking to the moment the agent starts. Under 500–700 ms feels natural; past that, it starts to feel like a bad phone connection.&lt;/p&gt;

&lt;p&gt;Those thresholds come from practitioners. AssemblyAI's April 2026 breakdown budgets &lt;strong&gt;600–900 ms&lt;/strong&gt; for a fully streamed pipeline; &lt;a href="https://prodinit.com/blog/production-voice-ai-agents-latency-architecture" rel="noopener noreferrer"&gt;Prodinit's 2026 production guide&lt;/a&gt; puts the reliability floor at &lt;strong&gt;sub-800 ms p95&lt;/strong&gt; and calls sub-250 ms p50 achievable. AssemblyAI also notes that responses beyond 500–700 ms "start to feel unnatural."&lt;/p&gt;

&lt;p&gt;Here's a dated budget, stage by stage, from the published sources next to &lt;strong&gt;our own measured build&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;Stage&lt;/th&gt;
&lt;th&gt;Industry budget (AssemblyAI, 2026-04-29)&lt;/th&gt;
&lt;th&gt;Our measured run (2026-07-14, M1 Pro, cloud)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Speech-to-text&lt;/td&gt;
&lt;td&gt;200–500 ms&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;~1,200–1,800 ms&lt;/strong&gt; (the bottleneck)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM (first token)&lt;/td&gt;
&lt;td&gt;150–400 ms&lt;/td&gt;
&lt;td&gt;~800–1,400 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Text-to-speech (first audio)&lt;/td&gt;
&lt;td&gt;200–400 ms&lt;/td&gt;
&lt;td&gt;~500–700 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network / transport&lt;/td&gt;
&lt;td&gt;50–150 ms&lt;/td&gt;
&lt;td&gt;included above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;End-to-end&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;600–900 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2,500–3,900 ms to first audio&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Barge-in (interruption)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;≈ 1 ms&lt;/strong&gt; (server-side VAD)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Let me be honest about our numbers. &lt;strong&gt;We built a real voice agent and measured it&lt;/strong&gt; on 2026-07-14 — a MacBook Pro, 16 GB RAM, on a residential network, running a cloud stack of OpenAI GPT plus Whisper STT plus OpenAI TTS with streaming enabled. These are our numbers on our hardware, not a vendor performance guarantee. The full build and method are in our &lt;a href="https://dev.to/alakkadshaw/build-an-ai-voice-agent-in-typescript-cloud-or-100-local-one-config-swap-40f1"&gt;TypeScript voice-agent tutorial&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;End to end, we measured roughly &lt;strong&gt;2.5–3.9 seconds&lt;/strong&gt; to first audio on the default buffered pipeline. That's slower than the streamed-ideal industry budget — roughly 200–500 ms for STT, 150–400 ms for the LLM's first token, 200–400 ms for TTS, and 50–150 ms of network, about 600–900 ms end to end (AssemblyAI, 2026-04-29). The gap between that textbook 600–900 ms and a real 2.5–3.9 s is buffering and cold starts, not model quality — which is exactly the point.&lt;/p&gt;

&lt;p&gt;And here's the insight that reorganizes how you optimize: &lt;strong&gt;STT dominates.&lt;/strong&gt; Buffered Whisper alone ate 1.2–1.8 seconds — the single biggest slice. The LLM (~0.8–1.4 s) and TTS (~0.5–0.7 s) were not our bottleneck; transcription was. If you're trying to make an agent feel faster, the streaming STT model is usually the highest-leverage lever, not a bigger LLM.&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%2Fh6praxdzkief06oseh4n.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%2Fh6praxdzkief06oseh4n.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One more number worth staring at: barge-in. When we talked over the agent, the server's voice-activity detection cancelled its speech within about &lt;strong&gt;1 millisecond&lt;/strong&gt; of detecting our voice. Interruption handling isn't a latency problem — it's an architecture problem, and it belongs on the server, close to the media.&lt;/p&gt;

&lt;p&gt;Which brings us back to the connectivity thread. All of this latency assumes the audio connects in the first place. On a lot of networks, it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Voice Agents Fail on Corporate, Hospital, and Mobile Networks
&lt;/h2&gt;

&lt;p&gt;This is the failure mode that ships to production and blindsides teams: the agent works perfectly on your Wi-Fi and goes silent in a customer's office. The call connects, the transcript even flows — and there's no audio.&lt;/p&gt;

&lt;p&gt;It's not a bug in your code. It's the network, and it's predictable.&lt;/p&gt;

&lt;p&gt;WebRTC wants a direct peer-to-peer path over UDP. Restrictive networks break that in two ways. Many corporate, hospital, and mobile carrier networks use &lt;strong&gt;symmetric NAT&lt;/strong&gt;, which scrambles the address mapping so the two sides can't agree on where to send packets. Others simply &lt;strong&gt;block the UDP ports&lt;/strong&gt; WebRTC reaches for.&lt;/p&gt;

&lt;p&gt;Here's the pattern we see over and over: it works on the developer's home Wi-Fi and dies the moment a real user is on a corporate LAN or on cellular. Same code, different network, silent call.&lt;/p&gt;

&lt;p&gt;Now add the twist that makes this worse for agents specifically. A browser-to-browser call can sometimes fall back to a direct path between two consumer networks. &lt;strong&gt;A server-side voice agent has no peer-to-peer fallback&lt;/strong&gt; — one end is a machine in a data center — so when the direct path fails, there is no plan B on the same wire. The audio has to be relayed, which is why agent media is almost always relayed in production. We wrote up that reasoning in depth in &lt;a href="https://dev.to/alakkadshaw/turn-for-ai-voice-agents-why-agent-traffic-is-almost-100-relay-3meo"&gt;TURN for AI voice agents&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%2F5kcttjn0c28nx7vjvwmx.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%2F5kcttjn0c28nx7vjvwmx.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the uncomfortable truth is that "it works on my machine" is the default state of a WebRTC voice agent, and it lies to you. The networks where it fails are precisely the networks your paying customers sit on.&lt;/p&gt;

&lt;p&gt;The fix is a relay. And that's where STUN and TURN come in.&lt;/p&gt;

&lt;h2&gt;
  
  
  STUN, TURN, and What a Production Agent Actually Needs
&lt;/h2&gt;

&lt;p&gt;Two acronyms do the connectivity work, and they are not the same thing. &lt;strong&gt;STUN&lt;/strong&gt; helps a client discover its own public address so two peers can try a direct connection. &lt;strong&gt;TURN&lt;/strong&gt; is the fallback that actually &lt;strong&gt;relays&lt;/strong&gt; the media through a server when the direct path fails. STUN discovers; TURN relays. Confusing the two is the most common mistake in this whole topic.&lt;/p&gt;

&lt;p&gt;For a production voice agent, you need TURN. The direct path fails often enough on real networks — and a server-side agent has no peer-to-peer fallback — that a relay isn't a nice-to-have, it's the thing standing between "works in the demo" and "works for customers."&lt;/p&gt;

&lt;p&gt;There's a detail that matters for the hardest networks: &lt;strong&gt;TURN over TLS on port 443&lt;/strong&gt;. Locked-down corporate and hospital firewalls that block everything else usually still allow outbound 443, because that's where normal HTTPS lives. A TURN server that speaks TURNS on 443 looks like ordinary web traffic and gets through where raw UDP is dead on arrival.&lt;/p&gt;

&lt;p&gt;So how do you get a TURN server? You have two honest paths, and both are legitimate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run your own&lt;/strong&gt; with an open-source server like &lt;a href="https://www.metered.ca/blog/coturn/" rel="noopener noreferrer"&gt;coturn&lt;/a&gt;. It's free software, but you own the config, the TLS certificates, the ports, the capacity planning, and the bandwidth bill. It's real DevOps work, and the bandwidth adds up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use a managed TURN service&lt;/strong&gt; and skip the operations. You can start free — &lt;a href="https://www.metered.ca/tools/openrelay/" rel="noopener noreferrer"&gt;Open Relay&lt;/a&gt; gives you &lt;strong&gt;20 GB/month of TURN bandwidth&lt;/strong&gt; at no cost, on ports 80 and 443 with TURNS, which is plenty for small workloads. When you outgrow it, &lt;a href="https://www.metered.ca/stun-turn" rel="noopener noreferrer"&gt;Metered's TURN service&lt;/a&gt; starts with a 500 MB free trial and then runs Growth at $99 for 150 GB, Business at $199 for 500 GB, and Enterprise at $499 for 2 TB, across 31+ regions and 100+ edge locations.&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%2Fbr3352fsbk6u9vxx4ote.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%2Fbr3352fsbk6u9vxx4ote.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You don't have to decide today. The point is that a TURN relay is part of the voice-agent stack, full stop — not an optional extra you bolt on after launch, but the layer that makes the other four work on real networks. If you want to check whether your own agent's path holds up, you can &lt;a href="https://www.metered.ca/turn-server-testing" rel="noopener noreferrer"&gt;test a TURN connection&lt;/a&gt; before your users do it for you.&lt;/p&gt;

&lt;p&gt;That's the whole stack, actually. Let's map it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Voice-Agent Stack You Actually Need
&lt;/h2&gt;

&lt;p&gt;A voice-agent stack has six layers, and most guides only cover the first four. You need speech-to-text, an LLM, text-to-speech, an orchestrator for turn-taking and barge-in, a media-transport layer (WebRTC) to move the audio, and — for production reliability — a TURN relay plus signalling to establish the connection.&lt;/p&gt;

&lt;p&gt;The first four are the conversation. The last two are the connection. Skip the connection layers and you get an agent that demos beautifully and fails in the field.&lt;/p&gt;

&lt;p&gt;Metered is &lt;strong&gt;not&lt;/strong&gt; a voice-agent platform, and this isn't a pitch to replace your STT, LLM, or TTS. It is the infrastructure under the stack — the transport and connectivity layer that sits beneath &lt;em&gt;any&lt;/em&gt; voice agent, whether you built it yourself or bought a hosted one.&lt;/p&gt;

&lt;p&gt;That layer is two things. &lt;strong&gt;TURN&lt;/strong&gt; relays the media so the agent connects on real networks; Open Relay covers the free tier and the &lt;a href="https://www.metered.ca/stun-turn" rel="noopener noreferrer"&gt;Metered TURN service&lt;/a&gt; covers scale, regions, and analytics. And &lt;strong&gt;signalling&lt;/strong&gt; is the coordination channel that helps the two sides find each other and exchange connection details before the media flows.&lt;/p&gt;

&lt;p&gt;On signalling, one honest note so you can plan the whole connection layer: &lt;a href="https://www.metered.ca/tools/openrelay/webrtc-signaling-server" rel="noopener noreferrer"&gt;Metered Realtime&lt;/a&gt; is a managed signalling service you can &lt;strong&gt;start free&lt;/strong&gt;, with an MIT-licensed open-source client. &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%2Fqbwe59o6zost1o2nydfp.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%2Fqbwe59o6zost1o2nydfp.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the stack top to bottom and the thesis of this whole guide falls out. The top four layers are where the intelligence lives, and they're mostly solved by picking good models. The bottom two are where agents actually break, and they're an infrastructure problem — latency and connectivity — not a prompt problem.&lt;/p&gt;

&lt;p&gt;So how do you assemble all six? You have three routes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Build a Voice Agent
&lt;/h2&gt;

&lt;p&gt;build it open-source.** Assemble it yourself for full control and provider freedom. Our own free, open-source TypeScript SDK, &lt;a href="https://llmrtc.org" rel="noopener noreferrer"&gt;LLMRTC&lt;/a&gt;, handles the voice-agent hard parts — WebRTC transport, server-side voice-activity detection, natural barge-in, tool calling, and a provider-agnostic pipeline so you can swap OpenAI, Anthropic, Gemini, or local models by config. It's Apache-2.0 and truly free, built by a team that runs production WebRTC infrastructure. Its own docs tell you to put a TURN server in front of it for users behind NAT — because, as this article has hammered, you need one.&lt;/p&gt;

&lt;p&gt;We proved that route end-to-end: our &lt;a href="https://dev.to/alakkadshaw/build-an-ai-voice-agent-in-typescript-cloud-or-100-local-one-config-swap-40f1"&gt;TypeScript voice-agent tutorial&lt;/a&gt; builds a real agent and swaps the entire stack from cloud to 100% local with one config change. And if you're wiring a browser straight to a hosted realtime model, our walkthrough of &lt;a href="https://medium.com/@jamesbordane57/openai-realtime-api-over-webrtc-how-it-works-when-you-need-turn-2a3abcdda451" rel="noopener noreferrer"&gt;OpenAI Realtime over WebRTC&lt;/a&gt; shows exactly when TURN enters the picture — the direct browser-to-OpenAI path connected with no &lt;code&gt;iceServers&lt;/code&gt; at all, and TURN came back the moment we owned a leg of the connection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route 2: hybrid.&lt;/strong&gt; Use a platform or a hosted model for the conversation, and own the connectivity layer yourself — WebRTC transport, TURN relay, signalling — so you control quality, regions, and cost on the part that actually breaks. This is where most serious deployments land, and it's the route where an independent TURN service earns its keep.&lt;/p&gt;

&lt;p&gt;Whichever route you take, the connection layer is yours to get right. No platform makes the network problem disappear if you own any leg of the WebRTC path.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What is an AI voice agent?
&lt;/h3&gt;

&lt;p&gt;An AI voice agent is an autonomous, voice-first system that holds a natural spoken conversation, reasons about it in real time with a large language model, and takes action across connected systems — without a human scripting each turn. It combines speech-to-text, an LLM, and text-to-speech, connected over a real-time media transport like WebRTC.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do AI voice agents work?
&lt;/h3&gt;

&lt;p&gt;They run four real-time stages: speech-to-text transcribes the user, an LLM decides the response and calls tools, text-to-speech speaks it, and an orchestrator manages turn-taking and interruptions. Streaming overlaps the stages to cut latency. A media-transport layer — usually WebRTC — carries the audio between the user and the agent.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much latency is acceptable for a voice agent?
&lt;/h3&gt;

&lt;p&gt;Keep end-to-end response under about 800 ms at p95, from end of speech to first audio; under 500–700 ms feels natural, and sub-250 ms p50 is achievable with a fully streamed stack. A naive, non-streaming pipeline adds 2–4 seconds of dead air, which breaks the conversation. Transport is part of that budget.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do voice agents need a TURN server?
&lt;/h3&gt;

&lt;p&gt;In production, usually yes. A server-side agent's WebRTC media has no peer-to-peer fallback, and many corporate and mobile networks block direct UDP, so the audio must be relayed through a TURN server. You can start free with Open Relay's 20 GB/month and move to a managed TURN service for regions, capacity, and analytics as you scale.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my voice agent work locally but fail on office or hospital Wi-Fi?
&lt;/h3&gt;

&lt;p&gt;Restrictive networks use symmetric NAT and block the UDP ports WebRTC needs, so the direct media path can't form. A server-side agent has no peer-to-peer fallback, so its audio is almost always relayed — which means it needs a TURN server. Without one, the call connects and then goes silent.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the difference between speech-to-speech and a cascaded pipeline?
&lt;/h3&gt;

&lt;p&gt;A cascaded pipeline chains three models — STT, LLM, TTS — giving you a readable text artifact at each step to log, filter, or route. Speech-to-speech uses one model from audio in to audio out: often more natural, but harder to debug, more expensive, and less transparent. As of April 2026, cascade still dominates production.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the difference between a voice agent and an IVR or chatbot?
&lt;/h3&gt;

&lt;p&gt;An IVR forces callers down fixed menus ("press 1 for sales"); a chatbot handles typed text. A voice agent understands natural spoken language, reasons with an LLM, takes actions in backend systems, and replies in natural speech — no scripted menu tree, and it works over the phone or the web.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;An AI voice agent is four models in a loop — hear, think, speak, and a conductor to run them — riding on two layers of connectivity most guides never mention. Get the models right and you have a demo. Get the transport and relay right and you have a product.&lt;/p&gt;

&lt;p&gt;That's the whole argument: &lt;strong&gt;voice agents are a latency and connectivity problem, not a prompt problem.&lt;/strong&gt; The prompt is the part you'll finish first. The media path is the part that decides whether your agent feels human or goes silent on the exact networks your customers use.&lt;/p&gt;

&lt;p&gt;So build the conversation however you like — a platform, open-source, or a hybrid. But own the connection layer. Start free on &lt;a href="https://www.metered.ca/tools/openrelay/" rel="noopener noreferrer"&gt;Open Relay's 20 GB/month&lt;/a&gt;, and when real users behind real firewalls show up, &lt;a href="https://www.metered.ca/stun-turn" rel="noopener noreferrer"&gt;Metered's TURN service&lt;/a&gt; relays the audio across 31+ regions so your agent connects everywhere — not just on your Wi-Fi.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;About the author:&lt;/strong&gt; This guide was written by James Bordane
&lt;/h2&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>programming</category>
      <category>webrtc</category>
    </item>
    <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>
  </channel>
</rss>
