<?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: AiLaoHuYu</title>
    <description>The latest articles on DEV Community by AiLaoHuYu (@ailaohuyu).</description>
    <link>https://dev.to/ailaohuyu</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%2F4131899%2F9201723a-4e5b-4b8e-93f8-ce2621485174.png</url>
      <title>DEV Community: AiLaoHuYu</title>
      <link>https://dev.to/ailaohuyu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ailaohuyu"/>
    <language>en</language>
    <item>
      <title>Jitsi is the right default for meetings. Here is the narrower case where it is not</title>
      <dc:creator>AiLaoHuYu</dc:creator>
      <pubDate>Wed, 21 Oct 2026 07:24:49 +0000</pubDate>
      <link>https://dev.to/ailaohuyu/jitsi-is-the-right-default-for-meetings-here-is-the-narrower-case-where-it-is-not-52oe</link>
      <guid>https://dev.to/ailaohuyu/jitsi-is-the-right-default-for-meetings-here-is-the-narrower-case-where-it-is-not-52oe</guid>
      <description>&lt;p&gt;&lt;strong&gt;Disclosure: I sell Low-Latency Kit, a commercial WebRTC source kit. This post compares it honestly with Jitsi Meet, an Apache-2.0 project. I do not earn anything from a Jitsi deployment.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Jitsi Meet is the stronger default when the product you are building is a meeting.&lt;/p&gt;

&lt;p&gt;It has multi-party rooms, mature browser and mobile clients, authentication integrations, recording and streaming options, and an established deployment ecosystem. If users need to join a room, see a grid of participants, mute each other, share a screen, and use the same experience on desktop and mobile, adopting Jitsi is usually the shorter path.&lt;/p&gt;

&lt;p&gt;Low-Latency Kit solves a narrower problem: &lt;strong&gt;one phone or screen publisher to one or many browser viewers&lt;/strong&gt;. The camera is effectively a remote pair of eyes. The important parts are the media path, what happens when frames stop, and whether an operator can see why the session failed.&lt;/p&gt;

&lt;p&gt;I wrote a fuller comparison here: &lt;a href="https://lowlatencykit.com/compare/jitsi/" rel="noopener noreferrer"&gt;Jitsi Meet vs Low-Latency Kit&lt;/a&gt;. This post is about the engineering decision behind that comparison, not the feature count.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure that makes the two products diverge
&lt;/h2&gt;

&lt;p&gt;In a meeting, the room is the unit of the product. In a remote inspection or field-support workflow, the camera session is the unit. That changes the failure model.&lt;br&gt;
A conferencing UI can show a participant tile, a connection state, a mute icon, and a device selector. A field-support UI usually needs to answer different questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the publisher still sending new frames, or is the last frame frozen?&lt;/li&gt;
&lt;li&gt;Is the selected candidate pair direct or relayed through TURN?&lt;/li&gt;
&lt;li&gt;Did the WebSocket reconnect, and did the publisher rejoin the correct session?&lt;/li&gt;
&lt;li&gt;Is the failure packet loss, bandwidth limitation, CPU pressure, or no picture at all?&lt;/li&gt;
&lt;li&gt;Can the session recover without asking the technician to reload the page?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These questions matter because &lt;code&gt;iceConnectionState === "connected"&lt;/code&gt; does not prove that video is moving. A track can remain live while the displayed frame is stale. Browser APIs commonly report the connection as connected during exactly the kind of mobile-network transition that makes a support call useless.&lt;/p&gt;

&lt;p&gt;The recovery chain I use has three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Detect a frame-level stall with &lt;code&gt;requestVideoFrameCallback&lt;/code&gt;, falling back to &lt;code&gt;currentTime&lt;/code&gt; when needed.&lt;/li&gt;
&lt;li&gt;Try an ICE restart on the existing peer connection.&lt;/li&gt;
&lt;li&gt;Rejoin the session only if the restart does not produce frames.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The order is deliberate. An ICE restart is cheaper and preserves the existing media tracks. A full rejoin is the fallback for stale server-side session state, a signaling reconnect, or a republished track. Repeated rejoins need jittered exponential backoff so a tower coming back online does not cause a retry storm.&lt;/p&gt;

&lt;p&gt;You can try the failure path in a browser with no account or installation: &lt;a href="https://lowlatencykit.com/demo/webrtc-recovery/" rel="noopener noreferrer"&gt;the 18-second freeze and recovery demo&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Jitsi is the better engineering choice
&lt;/h2&gt;

&lt;p&gt;Choose Jitsi if any of these statements describes the product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Users need more than one participant to publish media.&lt;/li&gt;
&lt;li&gt;The primary interface is a meeting room.&lt;/li&gt;
&lt;li&gt;You need the Jitsi mobile applications, meeting UI, or authentication integrations.&lt;/li&gt;
&lt;li&gt;Your team already operates the Java, web, and supporting services in a Jitsi deployment.&lt;/li&gt;
&lt;li&gt;You need the Apache-2.0 licence path and not a commercial source licence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are not small advantages. Rebuilding a meeting product around a small kit would be worse engineering, even if the kit has better diagnostics for a frozen viewer.&lt;/p&gt;

&lt;p&gt;Jitsi is also the safer choice when procurement or legal requires a well-known open-source project and an established licence model. Verify the current project and deployment requirements before deciding because licences and supported topologies can change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a one-publisher kit makes sense
&lt;/h2&gt;

&lt;p&gt;Consider a smaller kit when the workflow is closer to this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A technician points a phone camera at a machine, asset, or fault.&lt;/li&gt;
&lt;li&gt;One or more experts watch in a browser.&lt;/li&gt;
&lt;li&gt;The expert does not need to publish video or manage a meeting.&lt;/li&gt;
&lt;li&gt;The application owns the buttons, branding, session IDs, and business workflow.&lt;/li&gt;
&lt;li&gt;The media path must stay on infrastructure the buyer controls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is a different product from a conference room. The buyer usually has an existing field-service app, inspection workflow, teleoperation dashboard, or IoT camera interface. The missing piece is the reliable camera session and its operational details, not another meeting application.&lt;/p&gt;

&lt;p&gt;The kit is source code, not a hosted service. It includes a Go signalling server, a Pion SFU, a browser publisher and viewer, reconnect logic, diagnostics, Docker and Caddy deployment examples, TURN wiring, and notes on the failure modes that showed up in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  A deployment mistake worth checking first
&lt;/h2&gt;

&lt;p&gt;One of the most common cloud failures has nothing to do with JavaScript. The server has a private network interface, while the public address is supplied by the provider NAT. If the server advertises its private address in SDP, a remote browser may never reach the media path. On a TURN server, the equivalent setting is &lt;code&gt;external-ip&lt;/code&gt;. In a Go server, the deployment needs a public IP flag or a relay-only policy. The symptom is easy to miss: two machines on the same office network connect instantly, while an outside browser never receives frames.&lt;/p&gt;

&lt;p&gt;Another common case is the phone moving from Wi-Fi to cellular. The old five-tuple is gone, so the old candidate pair cannot be revived. The connection object may keep saying connected while the viewer is looking at a still image. The application needs frame health, ICE restart, a full-rejoin fallback, and useful diagnostics. This is exactly the gap between a WebRTC demo and a support product.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would decide from
&lt;/h2&gt;

&lt;p&gt;If the product needs a meeting, choose Jitsi. You will spend your time on the workflow instead of rebuilding rooms and mobile clients.&lt;/p&gt;

&lt;p&gt;If the product needs one camera publisher, many viewers, a self-hosted media path, and visible recovery behavior, the narrower kit is worth evaluating. Start with the live demo, then read the implementation notes and pricing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://lowlatencykit.com/demo/webrtc-recovery/" rel="noopener noreferrer"&gt;Live freeze and recovery demo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://lowlatencykit.com/compare/jitsi/" rel="noopener noreferrer"&gt;Full Jitsi comparison&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://lowlatencykit.com/#buy" rel="noopener noreferrer"&gt;Low-Latency Kit source package&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The source package is a one-time purchase with a seven-day refund window. A lower-priced copy is also available through &lt;a href="https://www.codester.com/items/72624/lowlatencykit-self-hosted-webrtc-video-server?ref=LowLatency" rel="noopener noreferrer"&gt;Codester&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you are deciding between these paths, the useful question is not which project has more features. It is whether your users need a conference room or a remote pair of eyes.&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>javascript</category>
      <category>architecture</category>
      <category>docker</category>
    </item>
    <item>
      <title>Your WebRTC viewer says connected. The picture is frozen.</title>
      <dc:creator>AiLaoHuYu</dc:creator>
      <pubDate>Mon, 19 Oct 2026 02:57:34 +0000</pubDate>
      <link>https://dev.to/ailaohuyu/your-webrtc-viewer-says-connected-the-picture-is-frozen-5ce6</link>
      <guid>https://dev.to/ailaohuyu/your-webrtc-viewer-says-connected-the-picture-is-frozen-5ce6</guid>
      <description>&lt;p&gt;There is a failure mode in WebRTC that looks healthy from almost every angle:&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="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iceConnectionState&lt;/span&gt; &lt;span class="o"&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;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connectionState&lt;/span&gt; &lt;span class="o"&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;video&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;HTMLMediaElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HAVE_ENOUGH_DATA&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signaling socket is open. The peer connection has a candidate pair. The video&lt;br&gt;
element is attached. And the frame on screen has not moved for three minutes.&lt;/p&gt;

&lt;p&gt;That last sentence is the problem. &lt;code&gt;connected&lt;/code&gt; describes the transport, not the&lt;br&gt;
picture. A media track can be live while no new frames are rendered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lowlatencykit.com/demo/webrtc-recovery/" rel="noopener noreferrer"&gt;Try the freeze-and-recovery demo in your browser&lt;/a&gt;.&lt;/strong&gt; It freezes a real media stream, shows &lt;code&gt;Transport: connected&lt;/code&gt; throughout the outage, then walks through ICE restart and full rejoin. No install and no account.&lt;/p&gt;
&lt;h2&gt;
  
  
  Frames are the health check
&lt;/h2&gt;

&lt;p&gt;If the audience is watching video, the useful health signal is not a connection state.&lt;br&gt;
It is an advancing frame.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;requestVideoFrameCallback&lt;/code&gt; gives us that signal. It fires when the browser has a new&lt;br&gt;
video frame to present, and its metadata includes the presentation time:&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="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestVideoFrameCallback&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metadata&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="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;presentationTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&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;That one callback can drive both recovery and observability: the viewer is healthy when&lt;br&gt;
frames are arriving, and it is frozen when they stop.&lt;/p&gt;

&lt;p&gt;Older browsers may not implement the callback. There, a timer can watch&lt;br&gt;
&lt;code&gt;video.currentTime&lt;/code&gt;. It is less precise, but it still distinguishes playback from a&lt;br&gt;
still image.&lt;/p&gt;
&lt;h2&gt;
  
  
  Recover in the cheapest order
&lt;/h2&gt;

&lt;p&gt;When frames stop, the instinct is to tear down and reconnect everything. That works,&lt;br&gt;
but it is the most expensive option: a new socket, a new offer, a new answer and a new&lt;br&gt;
peer connection. Most interruptions do not require all of that.&lt;/p&gt;

&lt;p&gt;I use a three-step recovery ladder:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;ICE restart.&lt;/strong&gt; Reuse the current session and negotiate new candidates. This is
usually enough after a Wi-Fi change or a network path failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full rejoin.&lt;/strong&gt; Replace the signaling session and peer connection. This covers
server-side sessions that went stale or a publisher that republished.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backoff and retry.&lt;/strong&gt; Repeating full rejoins without a delay can turn one broken
client into a retry storm.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The deadlines matter. A restart that has not produced a new frame in five seconds is&lt;br&gt;
not a successful restart. A rejoin that has not produced a new frame in eight seconds&lt;br&gt;
is not a successful rejoin. The transition should be driven by rendered frames, not by&lt;br&gt;
the fact that a connection event fired.&lt;/p&gt;
&lt;h2&gt;
  
  
  A compact implementation
&lt;/h2&gt;

&lt;p&gt;I published the recovery logic as &lt;code&gt;webrtc-recovery&lt;/code&gt;. It contains no signaling or media&lt;br&gt;
server code; you provide &lt;code&gt;restart()&lt;/code&gt; and &lt;code&gt;rejoin()&lt;/code&gt; for your own stack.&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;createRecoveryLadder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;watchFrames&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;webrtc-recovery&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;ladder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createRecoveryLadder&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;restart&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="nx"&gt;signaling&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;restart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;rejoin&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;pc&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="nx"&gt;pc&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;signaling&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reconnect&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="nf"&gt;watchFrames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;onStall&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="nx"&gt;ladder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onIceState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;onResume&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="nx"&gt;ladder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;markHealthy&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestVideoFrameCallback&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;tick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ladder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;markHealthy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;frame&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestVideoFrameCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&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 important part is the invariant: only a rendered frame clears the recovery&lt;br&gt;
deadline. A successful &lt;code&gt;setRemoteDescription&lt;/code&gt; or an &lt;code&gt;iceConnectionState&lt;/code&gt; transition is&lt;br&gt;
not evidence that somebody can see the stream.&lt;/p&gt;

&lt;p&gt;The package is ESM-only, has zero runtime dependencies, and the state machine accepts&lt;br&gt;
an injected clock, so the timeout branches can be tested without real waits. The&lt;br&gt;
current release is available from GitHub while the npm listing is being prepared:&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 &lt;/span&gt;https://github.com/AiLaoHuYu/webrtc-recovery/archive/refs/tags/v0.1.1.tar.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repository:&lt;br&gt;
&lt;a href="https://github.com/AiLaoHuYu/webrtc-recovery" rel="noopener noreferrer"&gt;https://github.com/AiLaoHuYu/webrtc-recovery&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnose one thing, not ten numbers
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;getStats()&lt;/code&gt; can return dozens of numbers at once. A viewer does not need a dashboard;&lt;br&gt;
it needs the first useful explanation.&lt;/p&gt;

&lt;p&gt;Check the highest-severity condition first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the transport failed or closed?&lt;/li&gt;
&lt;li&gt;Is the frame watchdog reporting a stall?&lt;/li&gt;
&lt;li&gt;Is packet loss already bad enough to explain poor quality?&lt;/li&gt;
&lt;li&gt;Is the jitter buffer growing?&lt;/li&gt;
&lt;li&gt;Is the sender capped by bandwidth or CPU?&lt;/li&gt;
&lt;li&gt;Is signaling up but no frame dimensions arriving?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That ordering avoids the common mistake of telling a user about 3% packet loss while&lt;br&gt;
the real problem is that the peer connection is dead.&lt;/p&gt;

&lt;p&gt;The same package exposes &lt;code&gt;sampleConnection()&lt;/code&gt; to flatten a stats report and&lt;br&gt;
&lt;code&gt;diagnose()&lt;/code&gt; to turn the first actionable condition into one sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not solve
&lt;/h2&gt;

&lt;p&gt;Client recovery cannot fix a server that is down, a publisher that stopped sending&lt;br&gt;
media, or a TURN service with no capacity. It also cannot make an expired signaling&lt;br&gt;
session valid.&lt;/p&gt;

&lt;p&gt;What it can do is stop a viewer from sitting on a frozen frame while every status badge&lt;br&gt;
says everything is fine. In a live video product, that difference is the whole user&lt;br&gt;
experience.&lt;/p&gt;

&lt;p&gt;If you are assembling the rest of the stack yourself, the library and source are here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/AiLaoHuYu/webrtc-recovery" rel="noopener noreferrer"&gt;https://github.com/AiLaoHuYu/webrtc-recovery&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you would rather start from the full self-hosted WebRTC video server, including&lt;br&gt;
signaling, the SFU, the publisher and the viewer, see &lt;a href="https://lowlatencykit.com" rel="noopener noreferrer"&gt;https://lowlatencykit.com&lt;/a&gt;.&lt;br&gt;
The complete source package is also available through&lt;br&gt;
&lt;a href="https://www.codester.com/items/72624/lowlatencykit-self-hosted-webrtc-video-server?ref=LowLatency" rel="noopener noreferrer"&gt;Codester&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Testing WebRTC without a camera: a loopback self-test that proves the whole path</title>
      <dc:creator>AiLaoHuYu</dc:creator>
      <pubDate>Tue, 13 Oct 2026 08:50:51 +0000</pubDate>
      <link>https://dev.to/ailaohuyu/testing-webrtc-without-a-camera-a-loopback-self-test-that-proves-the-whole-path-4olh</link>
      <guid>https://dev.to/ailaohuyu/testing-webrtc-without-a-camera-a-loopback-self-test-that-proves-the-whole-path-4olh</guid>
      <description>&lt;p&gt;The hardest part of testing a WebRTC pipeline is that the real one needs a camera, a microphone, a user who clicks allow, and HTTPS. On a build machine none of that exists, so the pipeline tests get skipped and the first honest test is a human on a laptop.&lt;/p&gt;

&lt;p&gt;There is a better fixture. Put a canvas on one end and a &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element on the other, and send the canvas through the actual stack — publisher, signaling, SFU, viewer — so what the test exercises is the real path and not a mock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The whole thing in one line: a canvas is an acceptable substitute for a camera, a timer is a better driver than an animation frame, and a moving timestamp is what proves the picture is live rather than merely present.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Replace the camera with a canvas
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;canvas.captureStream()&lt;/code&gt; returns a &lt;code&gt;MediaStream&lt;/code&gt; with the same shape as a camera's, so everything downstream is unchanged:&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;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;captureStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&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;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getVideoTracks&lt;/span&gt;&lt;span class="p"&gt;())&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;stream&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;No permission prompt, no device enumeration, no browser variation in which camera it picked. The test runs headless, on a server, in CI, and on a machine whose only camera is already in use by a call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Draw something that can only be live
&lt;/h2&gt;

&lt;p&gt;A static image is a trap. If the encoder dies and the last frame stays on screen, a static test pattern passes. Draw motion and a clock:&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;function&lt;/span&gt; &lt;span class="nf"&gt;draw&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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&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="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#101418&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&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;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// a moving block&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#5b8cff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#e6e9ef&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;20px monospace&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillText&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;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="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;34&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 timestamp is the assertion. A frozen stream shows a stopped clock, and a stream that is alive shows a clock that keeps moving, so the test can tell "connected and carrying video" from "connected and showing the last frame forever" without a human looking at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drive the canvas on a timer, not on &lt;code&gt;requestAnimationFrame&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is the detail that costs an afternoon if you get it wrong. A background tab gets no animation frames, so a &lt;code&gt;requestAnimationFrame&lt;/code&gt; draw loop stops the moment the tab is not visible — and the self-test then fails with a black or frozen viewer that has nothing to do with the network:&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;// Timer-driven, not requestAnimationFrame: a background tab gets no animation&lt;/span&gt;
&lt;span class="c1"&gt;// frames, which would silently stop the source and make the test look broken.&lt;/span&gt;
&lt;span class="nf"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;33&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 33 ms interval is roughly 30 fps and keeps producing frames while the tab is hidden, which is exactly when people leave a test running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Count liveness two ways, because one of them stops in a hidden tab
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;requestVideoFrameCallback&lt;/code&gt; is the most accurate way to count painted frames, and it also stops firing when the tab is backgrounded. So count both painted frames and decoded frames, and let the test pass if either one is advancing:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestVideoFrameCallback&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestVideoFrameCallback&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;tick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;painted&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="c1"&gt;// frames the user could actually see&lt;/span&gt;
    &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestVideoFrameCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&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;// getStats framesDecoded keeps advancing in a background tab.&lt;/span&gt;
&lt;span class="nf"&gt;setInterval&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&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;sample&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;prev&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;framesDecoded&lt;/span&gt; &lt;span class="o"&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="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One counter answers "can the user see it", the other answers "is the decoder still receiving". Using only the first produces a test that fails whenever it is not being watched, which trains everyone to ignore it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give every run its own room
&lt;/h2&gt;

&lt;p&gt;If the server enforces one publisher per room — and the simple SFU design does — then two concurrent self-tests collide: the second publisher evicts the first, and both tests report a failure that is really a scheduling coincidence.&lt;/p&gt;

&lt;p&gt;Derive the room name per visit instead of hardcoding 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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;selftest-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;?.()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line makes the test safe to run in parallel across browser tabs, machines and CI jobs with no coordination.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mirror the state so automation can assert it
&lt;/h2&gt;

&lt;p&gt;A test whose only output is pixels cannot be automated. Publish the state on a global object and assert on numbers:&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;st&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;publisher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;connecting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;connecting&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;frames&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="c1"&gt;// frames painted, via requestVideoFrameCallback&lt;/span&gt;
  &lt;span class="na"&gt;decoded&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="c1"&gt;// frames decoded, via getStats - works in a background tab&lt;/span&gt;
  &lt;span class="na"&gt;w&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="na"&gt;h&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="na"&gt;kbps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ice&lt;/span&gt;&lt;span class="p"&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__st&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;st&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the assertion is boring and reliable: wait for &lt;code&gt;ice&lt;/code&gt; to be &lt;code&gt;connected&lt;/code&gt;, wait for &lt;code&gt;decoded&lt;/code&gt; to exceed a threshold, and confirm the resolution is what you asked for. A driver script can do that in a few lines, and a failure tells you which of the three did not happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this test does and does not prove
&lt;/h2&gt;

&lt;p&gt;It proves the path end to end: track creation, offer/answer through the server, the SFU's fan-out, decoding, and the resolution and bitrate the session settled on. It catches the majority of regressions, because most of them break the path rather than a corner of it.&lt;/p&gt;

&lt;p&gt;It does not prove TURN works from outside your network — that needs a host with a different NAT, which is why the relay probe is a separate test. It does not prove camera or microphone behaviour, because there is no camera. And it does not prove quality under loss; for that you need a network emulator, not a loopback.&lt;/p&gt;

&lt;p&gt;Those boundaries are worth stating in the test itself. A self-test that claims to cover everything gets trusted for everything and then quietly lies.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fixture, in order
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Source from a canvas, not a camera — headless, no permissions, no HTTPS requirement.&lt;/li&gt;
&lt;li&gt;Draw a moving element and a monotonic timestamp so a frozen frame cannot pass.&lt;/li&gt;
&lt;li&gt;Drive the canvas with a timer, because a hidden tab stops animation frames.&lt;/li&gt;
&lt;li&gt;Count painted frames &lt;em&gt;and&lt;/em&gt; decoded frames, and accept either as liveness.&lt;/li&gt;
&lt;li&gt;Give each run a unique room so parallel tests cannot evict each other.&lt;/li&gt;
&lt;li&gt;Expose the state on a global so assertions are numeric, not visual.&lt;/li&gt;
&lt;li&gt;Be explicit about what the test does not cover, and keep the relay and loss tests separate.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This loopback test ships with the self-hosted WebRTC kit — canvas publisher, server relay, viewer, and the same stats panel the production viewer uses — so the whole path can be verified before a camera or a network is involved:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lowlatencykit.com/" rel="noopener noreferrer"&gt;https://lowlatencykit.com/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The free relay probe, which covers the one thing the loopback test cannot, needs no account:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lowlatencykit.com/test/turn/" rel="noopener noreferrer"&gt;https://lowlatencykit.com/test/turn/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>testing</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The six getStats numbers that actually tell you what broke</title>
      <dc:creator>AiLaoHuYu</dc:creator>
      <pubDate>Tue, 13 Oct 2026 08:49:17 +0000</pubDate>
      <link>https://dev.to/ailaohuyu/the-six-getstats-numbers-that-actually-tell-you-what-broke-40ld</link>
      <guid>https://dev.to/ailaohuyu/the-six-getstats-numbers-that-actually-tell-you-what-broke-40ld</guid>
      <description>&lt;p&gt;&lt;code&gt;getStats()&lt;/code&gt; returns a few hundred fields across dozens of records, and almost all of them are useless for diagnosis. The problem is not that the data is missing. It is that the numbers which matter only mean something in combination, and a dashboard that prints all of them tells you nothing.&lt;/p&gt;

&lt;p&gt;These are the six worth putting on a screen, in the order I would look at them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The whole thing in one line: the selected candidate pair tells you the path, the inbound counters tell you whether media arrived, and the outbound reason tells you why the encoder is holding back.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The selected candidate pair, and the candidate types behind it
&lt;/h2&gt;

&lt;p&gt;This is the one everyone gets wrong, because &lt;code&gt;getStats()&lt;/code&gt; reports many &lt;code&gt;candidate-pair&lt;/code&gt; records and only one of them is the path your media is actually on. Print the wrong pair and you will confidently announce a direct connection while the session is relayed, or the reverse:&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="nx"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&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;r&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="s1"&gt;local-candidate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;r&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="s1"&gt;remote-candidate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;cands&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&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;r&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="s1"&gt;candidate-pair&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selected&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;succeeded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentRoundTripTime&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="nx"&gt;pair&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&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;Note the extra condition. Requiring &lt;code&gt;currentRoundTripTime != null&lt;/code&gt; filters out pair records that exist but never carried a round trip — which is most of them on a NAT-heavy session. Without it, a "succeeded" pair with no measured RTT can win and you report a path that was never used.&lt;/p&gt;

&lt;p&gt;The types are not on the pair. They live in separate records that the pair refers to by id, so you have to join them:&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;l&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cands&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localCandidateId&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;rm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cands&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remoteCandidateId&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidateType&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// host | srflx | prflx | relay&lt;/span&gt;
&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remoteType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rm&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;rm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidateType&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;host → host&lt;/code&gt; means a direct path on the same network. &lt;code&gt;relay → relay&lt;/code&gt; means both sides are going through TURN, which is the expected state behind a carrier NAT and the first thing to verify when someone says the relay is broken. Skipping the join is why people conclude "it is always relayed" or "it is never relayed".&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Inbound bitrate, which requires two samples
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;bytesReceived&lt;/code&gt; is a cumulative counter. You cannot read a rate out of a single snapshot, which is why so many quick debugging scripts print a number that keeps growing and call it bitrate:&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;// Pass the previous sample in; the delta is the rate.&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;prev&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesReceived&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;prev&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bitrateKbps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesReceived&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;prev&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="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesReceived&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One unit trap lives in that line: &lt;code&gt;(bytes delta * 8) / milliseconds&lt;/code&gt; is kilobits per second only because the division by a duration in milliseconds yields bits per millisecond — which is bits per second divided by a thousand. Get the units wrong here and your numbers are off by 1000, which looks like a codec problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;framesPerSecond&lt;/code&gt; and resolution
&lt;/h2&gt;

&lt;p&gt;These tell you whether the stream is alive at the quality you asked for. A stream that negotiated 1280×720 and settles at 320×180 is not broken; it is being told to back off, and the next two numbers say by what.&lt;/p&gt;

&lt;p&gt;Read &lt;code&gt;frameWidth&lt;/code&gt;/&lt;code&gt;frameHeight&lt;/code&gt; from the inbound video record rather than from the &lt;code&gt;video&lt;/code&gt; element's &lt;code&gt;videoWidth&lt;/code&gt;. The element reports the decoded frame, which lags behind a change and is zero until the first frame decodes — the exact moment you most need a number.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;qualityLimitationReason&lt;/code&gt; on the outbound side
&lt;/h2&gt;

&lt;p&gt;This is the single most underused field in WebRTC diagnostics. When the encoder reduces resolution or frame rate, it says why:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&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="s1"&gt;outbound-rtp&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="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&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;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qlr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qualityLimitationReason&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="c1"&gt;// bandwidth | cpu | other | none&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;code&gt;bandwidth&lt;/code&gt; means the estimator decided the path cannot carry more, and the fix is the network or the bitrate ceiling. &lt;code&gt;cpu&lt;/code&gt; means the sender could not encode fast enough, and the fix is on the machine — resolution, hardware encoding, or too many tabs doing the same thing. &lt;code&gt;other&lt;/code&gt; is usually a screen-share capture path that cannot keep up. Those are three different investigations, and without this field you are guessing between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. &lt;code&gt;framesDecoded&lt;/code&gt; versus &lt;code&gt;framesReceived&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This pair is how you separate transport from decode. Packets arriving while decoded frames stay flat means the media reached you and could not be turned into pictures — codec parameters, a missing keyframe, or a hardware decoder that gave up.&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="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;freezeCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;freezeCount&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;freezeCount&lt;/span&gt; &lt;span class="p"&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;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;framesDecoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;framesDecoded&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;framesDecoded&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;freezeCount&lt;/code&gt; is the browser's own opinion that the picture stopped, which is a useful cross-check on your own frame-gap detection. If your 2-second frame-gap timer says "frozen" and &lt;code&gt;freezeCount&lt;/code&gt; is still rising while &lt;code&gt;framesDecoded&lt;/code&gt; is not, you are looking at decode, not at the network. If &lt;code&gt;framesDecoded&lt;/code&gt; is climbing and the user still reports a frozen tile, the problem is downstream of the decoder — rendering, the element, or the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Jitter and the jitter buffer
&lt;/h2&gt;

&lt;p&gt;Jitter on its own is a poor number to alarm on, because a jitter buffer exists precisely to absorb it. The informative value is the average time a frame spent waiting in that buffer, which you compute from two counters:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jitterBufferDelay&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jitterBufferEmittedCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jbufMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jitterBufferDelay&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jitterBufferEmittedCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&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;That is total buffer delay divided by frames emitted, scaled to milliseconds: the average per-frame hold. A buffer sitting in the tens of milliseconds is doing its job. A buffer climbing into the hundreds means the network is delivering frames unevenly enough that latency is the price being paid to keep the picture smooth — and for a live support call, that trade is often the wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table that fits on one screen
&lt;/h2&gt;

&lt;p&gt;The rendering that is actually useful is thirteen rows and no graphs:&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;rows&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connState&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; / &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iceState&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bitrate in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bitrateKbps&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bitrateKbps&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; kbps&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;-&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Resolution&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; x &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FPS in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fps&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fps&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; fps&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;-&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Packets lost&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loss&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loss&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Jitter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jitterMs&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jitterMs&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; ms&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;-&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Jitter buffer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jbufMs&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jbufMs&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; ms&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;-&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RTT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rttMs&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rttMs&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; ms&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;-&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localType&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localType&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; -&amp;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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remoteType&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Freezes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;freezeCount&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;freezeCount&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bitrate out&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outKbps&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outKbps&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; kbps&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;-&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FPS out&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outFps&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outFps&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; fps&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;-&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Quality limit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qlr&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qlr&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sampled once a second, that table answers the questions support actually asks: is there a path, is media arriving, at what quality, and if it is degraded, is the encoder blaming the network or the CPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to read it in one pass
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;Path&lt;/code&gt; shows the candidate types. &lt;code&gt;relay -&amp;gt; relay&lt;/code&gt; on a cellular session is correct, not a bug.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Bitrate in&lt;/code&gt; and &lt;code&gt;FPS in&lt;/code&gt; flat or zero with &lt;code&gt;Connection&lt;/code&gt; connected is a media problem, not a signaling problem.&lt;/li&gt;
&lt;li&gt;Frames received climbing while &lt;code&gt;Frames decoded&lt;/code&gt; stays flat means the media arrived and could not be decoded.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Quality limit: bandwidth&lt;/code&gt; and &lt;code&gt;Quality limit: cpu&lt;/code&gt; are different bugs with different owners.&lt;/li&gt;
&lt;li&gt;A jitter buffer in the hundreds of milliseconds is the latency you are paying for smoothness.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Freezes&lt;/code&gt; rising is the browser agreeing with your own freeze detection.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I built this sampling and rendering into the diagnostics panel of a self-hosted WebRTC kit, next to the recovery ladder that acts on it — ICE restart first, full rejoin when that fails — so the numbers are visible while the failure is happening rather than reconstructed from a log afterwards:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lowlatencykit.com/" rel="noopener noreferrer"&gt;https://lowlatencykit.com/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The guide that covers the network layers these numbers are measuring is here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lowlatencykit.com/guides/remote-field-assist-mobile-webrtc/" rel="noopener noreferrer"&gt;https://lowlatencykit.com/guides/remote-field-assist-mobile-webrtc/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>javascript</category>
      <category>performance</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Coturn and Kurento in Docker Compose: the three addresses that decide everything</title>
      <dc:creator>AiLaoHuYu</dc:creator>
      <pubDate>Tue, 13 Oct 2026 08:47:17 +0000</pubDate>
      <link>https://dev.to/ailaohuyu/coturn-and-kurento-in-docker-compose-the-three-addresses-that-decide-everything-5ad</link>
      <guid>https://dev.to/ailaohuyu/coturn-and-kurento-in-docker-compose-the-three-addresses-that-decide-everything-5ad</guid>
      <description>&lt;p&gt;A containerised TURN and media stack fails in a way that is genuinely confusing: every container is running, the logs look clean, the ports are published, and no remote peer can get media. The cause is almost always that three different addresses were treated as one.&lt;/p&gt;

&lt;p&gt;They are not interchangeable. Each one answers a different question, and mixing them up produces silent failure rather than an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The whole thing in one line: &lt;code&gt;realm&lt;/code&gt; is for authentication, &lt;code&gt;external-ip&lt;/code&gt; is for relay candidates, and the advertised media address is for SDP — a stack that works needs all three correct, and none of them is a synonym for the others.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Address 1: the realm
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;realm&lt;/code&gt; is not an address at all in the transport sense. It is the authentication realm coturn sends in its TURN challenge, and it participates in long-term credential authentication. A stable hostname is easier to operate than an IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;realm&lt;/span&gt;=&lt;span class="n"&gt;turn&lt;/span&gt;.&lt;span class="n"&gt;example&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave it empty and every request is challenged with nothing, the client retries forever, and you get a wall of &lt;code&gt;401 Unauthorized&lt;/code&gt; with a client-side &lt;code&gt;701&lt;/code&gt; timeout. The startup log says so once — &lt;code&gt;Default realm:&lt;/code&gt; followed by nothing — and then never mentions it again. Read the ten lines before the first client request, not the hundred after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Address 2: &lt;code&gt;external-ip&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is the address that appears in relay candidates. On a cloud VM, coturn usually sees only a private interface while the public address is attached by the provider's NAT, so it must be told:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;external&lt;/span&gt;-&lt;span class="n"&gt;ip&lt;/span&gt;=&lt;span class="m"&gt;203&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;113&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;/&lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The paired form is the one people miss. &lt;code&gt;203.0.113.10&lt;/code&gt; is the public address, and &lt;code&gt;10.0.0.4&lt;/code&gt; must be the address coturn actually sees on the host. Get the second half wrong and coturn does not map the interface, the relay candidates stay private, and remote peers try to reach an address that only exists inside the VPC.&lt;/p&gt;

&lt;h2&gt;
  
  
  Address 3: the media address Kurento advertises
&lt;/h2&gt;

&lt;p&gt;Media servers have their own version of this problem, made worse by containers. A containerised Kurento is happy to advertise the &lt;code&gt;172.x&lt;/code&gt; address of its Docker interface in SDP, and no remote peer can route to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;kurento&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kurento/kurento-media-server:7.1.0&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;expose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8888"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;KMS_STUN_IP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;turn.example.com&lt;/span&gt;
      &lt;span class="na"&gt;KMS_STUN_PORT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3478"&lt;/span&gt;
      &lt;span class="na"&gt;KMS_TURN_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;turn:turn.example.com:3478?transport=udp"&lt;/span&gt;
      &lt;span class="na"&gt;KMS_EXTERNAL_IPV4&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;203.0.113.10"&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;server&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;KMS_EXTERNAL_IPV4&lt;/code&gt; is the public address Kurento puts in its SDP. Without it you get a session that negotiates, reports connected, and carries nothing — the classic "works locally, dead from a remote browser" shape.&lt;/p&gt;

&lt;p&gt;Two related traps in the same file. &lt;code&gt;KMS_STUN_IP&lt;/code&gt; has to be reachable &lt;em&gt;from inside the Kurento container&lt;/em&gt;, so &lt;code&gt;127.0.0.1&lt;/code&gt; and &lt;code&gt;localhost&lt;/code&gt; are wrong there. And &lt;code&gt;ws://kurento:8888/kurento&lt;/code&gt; is correct for your application service on the Compose network, but it is not a public endpoint and it is not a browser URL — that mistake looks like a firewall problem and is not one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give coturn host networking, and then drop the &lt;code&gt;ports:&lt;/code&gt; block
&lt;/h2&gt;

&lt;p&gt;On a Linux VPS, &lt;code&gt;network_mode: host&lt;/code&gt; is the least surprising option for coturn, because the relay range is a wide UDP span that bridged networking handles badly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;coturn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;coturn/coturn:4.6.3&lt;/span&gt;
    &lt;span class="na"&gt;network_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;host&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./turnserver.conf:/etc/coturn/turnserver.conf:ro&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/etc/coturn/turnserver.conf"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With host networking the container shares the host network namespace, so a &lt;code&gt;ports:&lt;/code&gt; block is not just redundant — it is misleading, because it suggests Docker is publishing something that is already directly exposed. Remove it and let the config file own the port and range.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose one credential mode, not two
&lt;/h2&gt;

&lt;p&gt;The other recurring mistake is layering both credential mechanisms and then wondering which one the client used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Development: &lt;code&gt;lt-cred-mech&lt;/code&gt; with an explicit &lt;code&gt;user=&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Production: &lt;code&gt;use-auth-secret&lt;/code&gt; with short-lived &lt;code&gt;timestamp:label&lt;/code&gt; usernames and HMAC-SHA1 passwords.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you move to &lt;code&gt;use-auth-secret&lt;/code&gt;, remove the static &lt;code&gt;user=&lt;/code&gt; lines unless you have deliberately created a separate path for an internal client.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;username = &amp;lt;unix-expiry&amp;gt;:&amp;lt;label&amp;gt;
password = base64(HMAC-SHA1(secret, username))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And keep the browser's credential out of the browser. The browser should receive credentials from your signaling backend, minted per session, because a long-lived credential embedded in JavaScript is a credential you cannot rotate.&lt;/p&gt;

&lt;p&gt;Note that &lt;code&gt;KMS_TURN_URL&lt;/code&gt; is used by Kurento itself, not by the browser. If Kurento needs a fixed URL, give it its own tightly scoped credential, rotate it deliberately, and do not reuse it for browser clients.&lt;/p&gt;

&lt;h2&gt;
  
  
  The relay range is a capacity number, not a formality
&lt;/h2&gt;

&lt;p&gt;Each concurrent TURN allocation consumes a relay port, and retries or multiple allocations can consume more than one. A ten-port range is a test configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;min&lt;/span&gt;-&lt;span class="n"&gt;port&lt;/span&gt;=&lt;span class="m"&gt;49152&lt;/span&gt;
&lt;span class="n"&gt;max&lt;/span&gt;-&lt;span class="n"&gt;port&lt;/span&gt;=&lt;span class="m"&gt;65535&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That default is safe and large. A small production deployment can run a few hundred ports once you have measured concurrency — but open the range in &lt;em&gt;both&lt;/em&gt; firewalls, the provider firewall and the host firewall. A control port that answers while the relay range is closed produces a &lt;code&gt;srflx&lt;/code&gt; candidate and no &lt;code&gt;relay&lt;/code&gt; candidate, which the browser reports as a generic failure with no hint about which of the two is blocked.&lt;/p&gt;

&lt;p&gt;Do not match Kurento's internal RTP range to coturn's. They are different pools with different owners.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transport order, and what Nginx should not do
&lt;/h2&gt;

&lt;p&gt;Prefer UDP, then add fallbacks deliberately: TCP on 3478, and TLS on 5349 or 443 if you have certificates, for networks that block UDP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;turn:turn.example.com:3478?transport=udp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not put TURN behind an HTTP reverse proxy. TURN is not HTTP. Nginx can terminate HTTPS/WSS for your application and proxy Kurento's WebSocket over the private Compose network, but the TURN listeners must remain direct UDP/TCP services, and Kurento's port &lt;code&gt;8888&lt;/code&gt; should stay unexposed to the internet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verification order worth following exactly
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Confirm the host is listening on 3478/UDP and on the configured relay range.&lt;/li&gt;
&lt;li&gt;Confirm the provider firewall and host firewall allow that same range.&lt;/li&gt;
&lt;li&gt;Run a Trickle ICE check and confirm a &lt;code&gt;relay&lt;/code&gt; candidate actually appears — not just an &lt;code&gt;srflx&lt;/code&gt; one.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;iceTransportPolicy: "relay"&lt;/code&gt; in a test browser and confirm a call still connects. That proves the relay path end to end.&lt;/li&gt;
&lt;li&gt;Inspect the Kurento SDP and confirm it advertises &lt;code&gt;KMS_EXTERNAL_IPV4&lt;/code&gt;, not a Docker address.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step three and step four are the ones people skip, and they are the only two that distinguish "TURN is running" from "TURN works". A relay that answers STUN and refuses allocations passes every health check you would think to write.&lt;/p&gt;

&lt;p&gt;If you want to run that check without setting up a browser test, the relay test on my site takes a TURN URL and credentials and reports which candidate types came back, and it is free and needs no account:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lowlatencykit.com/test/turn/" rel="noopener noreferrer"&gt;https://lowlatencykit.com/test/turn/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The longer-lived version of this article, with the complete Compose file, lives as a guide here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lowlatencykit.com/guides/coturn-kurento-docker/" rel="noopener noreferrer"&gt;https://lowlatencykit.com/guides/coturn-kurento-docker/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And the self-hosted WebRTC kit this came out of — Go signaling server, SFU, browser clients and the coturn configuration — is here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lowlatencykit.com/" rel="noopener noreferrer"&gt;https://lowlatencykit.com/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>docker</category>
      <category>devops</category>
      <category>networking</category>
    </item>
    <item>
      <title>One camera, many viewers: the fan-out details that decide whether viewer #2 works</title>
      <dc:creator>AiLaoHuYu</dc:creator>
      <pubDate>Tue, 13 Oct 2026 08:45:34 +0000</pubDate>
      <link>https://dev.to/ailaohuyu/one-camera-many-viewers-the-fan-out-details-that-decide-whether-viewer-2-works-5e0h</link>
      <guid>https://dev.to/ailaohuyu/one-camera-many-viewers-the-fan-out-details-that-decide-whether-viewer-2-works-5e0h</guid>
      <description>&lt;p&gt;The first viewer is easy. You put a &lt;code&gt;TrackLocalStaticRTP&lt;/code&gt; on the server, you write the publisher's packets into it, and the tile lights up. Then a second viewer joins and you find out that "forward the RTP" was never a complete design.&lt;/p&gt;

&lt;p&gt;The single-viewer case hides five decisions. Each one is small, and each one is the difference between a demo and a session that behaves the same way for viewer number two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The whole thing in one line: the publisher sends one stream, the server's egress scales with viewers, and every viewer needs its own negotiation plus its own keyframe request.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The publisher's uplink does not grow, and that is the entire point
&lt;/h2&gt;

&lt;p&gt;In a mesh, three viewers means the publisher encodes and uploads three streams. In an SFU it uploads one. The server reads each incoming packet once and writes it to a local track that is bound to every viewer's sender:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;webrtc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewTrackLocalStaticRTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;codec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RTPCodecCapability&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remote&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;remote&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StreamID&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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="c"&gt;// One reader for the upstream, one local track, N senders bound to it.&lt;/span&gt;
&lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&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="n"&gt;pkt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;readErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;remote&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadRTP&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;readErr&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;break&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;writeErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteRTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;writeErr&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;break&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;The consequence people miss is where the bandwidth lands. The field technician on a phone keeps a single modest uplink no matter how many people are watching, and the server pays the multiplication. That is the right trade for a support call — the person on site has the worst connection in the system, and the server has the best one.&lt;/p&gt;

&lt;p&gt;It is also the number you have to watch when you plan capacity: egress, not ingress, and it grows linearly with viewers per session.&lt;/p&gt;

&lt;h2&gt;
  
  
  The new viewer's black tile is a keyframe problem
&lt;/h2&gt;

&lt;p&gt;The first viewer connects while the encoder is producing frames, so a keyframe arrives quickly. A viewer that joins ten minutes into a session receives inter-frames that reference a keyframe it never saw. RTP flows, &lt;code&gt;framesReceived&lt;/code&gt; climbs, and the tile is black until the publisher's encoder happens to emit the next keyframe — which, for a long GOP, can be many seconds.&lt;/p&gt;

&lt;p&gt;The fix is to relay the viewer's feedback upstream. Browsers already send a Picture Loss Indication when they cannot decode; the server's job is to carry it to the publisher. But a PLI carries the SSRC it is about, and a PLI that arrives from a viewer is about the &lt;em&gt;server's&lt;/em&gt; local track, not the publisher's upstream track:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;ssrc&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="kt"&gt;uint32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remote&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SSRC&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;pkts&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;pkt&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;rtcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PictureLossIndication&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;pkt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MediaSSRC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ssrc&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;rtcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FullIntraRequest&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;pkt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MediaSSRC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ssrc&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;rtcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TransportLayerNack&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;pkt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MediaSSRC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ssrc&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="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pubPC&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteRTCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forward the packet unchanged and the publisher sees feedback about an SSRC it does not own, ignores it, and the new viewer waits out the GOP. Rewrite &lt;code&gt;MediaSSRC&lt;/code&gt; to the publisher's value and the keyframe arrives in milliseconds. This one line is the difference between "the second viewer is broken" and "the second viewer is instant", and nothing in the browser tells you which one you are looking at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Negotiate all the tracks in one offer, not one offer per track
&lt;/h2&gt;

&lt;p&gt;A publisher's &lt;code&gt;ontrack&lt;/code&gt; fires once per track. Audio and video often arrive within milliseconds of each other, and a naive implementation answers each one immediately, which puts two &lt;code&gt;setLocalDescription&lt;/code&gt; cycles in flight for the same viewer. That is offer glare on your own connection, and the symptoms are intermittent in the way that wastes an afternoon.&lt;/p&gt;

&lt;p&gt;Batch them. A short timer that coalesces whatever arrived into a single offer per viewer is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Room&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;scheduleNegotiateLocked&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="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;negTimer&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;negTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AfterFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;250&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Millisecond&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;negTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;viewers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;negotiateViewerLocked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&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;Two details make it safe: the timer is stored per room so a burst of track arrivals collapses into one callback, and the callback re-takes the lock and re-checks the state, because 250 milliseconds is plenty of time for a viewer to leave.&lt;/p&gt;

&lt;h2&gt;
  
  
  RTCP reads need a real buffer
&lt;/h2&gt;

&lt;p&gt;The control channel looks trivial until it does not. Read it with a buffer sized for the largest datagram you can receive, not for the packet you expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1500&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="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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="n"&gt;pkts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;rtcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;n&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An undersized buffer turns into a short read, &lt;code&gt;Unmarshal&lt;/code&gt; fails, and the feedback is silently dropped — which surfaces as poor quality that only appears on networks sending large compound RTCP packets.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the publisher changes, every viewer is stale
&lt;/h2&gt;

&lt;p&gt;If the publisher disconnects or republishes, the viewers' PeerConnections are holding transceivers that belong to a session that no longer exists. Patching them is not worth it; tear them down so the next negotiation starts clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// resetViewersLocked tears down every viewer's PeerConnection so the next&lt;/span&gt;
&lt;span class="c"&gt;// negotiation starts from scratch. Caller must hold r.mu.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pairs with the client-side ladder: the viewer's full-rejoin fallback exists precisely because the server rebuilt its side, and a viewer that reuses its old connection negotiates successfully and receives nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limits of this design
&lt;/h2&gt;

&lt;p&gt;Being explicit about the edges is more useful than pretending they are not there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One publisher per room.&lt;/strong&gt; Multiple publishers and stream mixing are a different product; this fan-out assumes a single source that everyone watches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The codec is forwarded as-is.&lt;/strong&gt; The server does not transcode, so if the publisher switches codec mid-session, viewer connections have to be rebuilt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No simulcast or SVC in this version.&lt;/strong&gt; Every viewer receives the same encoding, which is right for a support call with a handful of viewers and wrong for a 500-person webinar.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Knowing which of those you need is most of the architecture decision. For one technician showing a fault to two or three people, the simple version is not a compromise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six things that make viewer #2 work
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Read the upstream once; bind the same local track to every viewer's sender.&lt;/li&gt;
&lt;li&gt;Budget server egress, not publisher uplink, for capacity.&lt;/li&gt;
&lt;li&gt;Relay viewer PLI/NACK upstream with the &lt;em&gt;publisher's&lt;/em&gt; SSRC so a late joiner gets a keyframe immediately.&lt;/li&gt;
&lt;li&gt;Batch track arrivals into one offer per viewer instead of one offer per track.&lt;/li&gt;
&lt;li&gt;Give RTCP reads a full-size buffer and handle unmarshal failures explicitly.&lt;/li&gt;
&lt;li&gt;Rebuild viewer connections when the publisher's session ends, and let the client escalate to a full rejoin.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I built this fan-out, the batched negotiation, the RTCP relay and the client-side recovery ladder into a self-hosted WebRTC kit in Go — signaling server, SFU and browser clients in one binary, with a diagnostics panel that shows the selected candidate path and per-leg stats:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lowlatencykit.com/" rel="noopener noreferrer"&gt;https://lowlatencykit.com/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The field-assist guide, which is the deployment this design is tuned for, is here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lowlatencykit.com/guides/remote-field-assist-mobile-webrtc/" rel="noopener noreferrer"&gt;https://lowlatencykit.com/guides/remote-field-assist-mobile-webrtc/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>go</category>
      <category>performance</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A frozen video tile and a working ICE state: the three-tier recovery ladder</title>
      <dc:creator>AiLaoHuYu</dc:creator>
      <pubDate>Tue, 13 Oct 2026 08:42:19 +0000</pubDate>
      <link>https://dev.to/ailaohuyu/a-frozen-video-tile-and-a-working-ice-state-the-three-tier-recovery-ladder-4jio</link>
      <guid>https://dev.to/ailaohuyu/a-frozen-video-tile-and-a-working-ice-state-the-three-tier-recovery-ladder-4jio</guid>
      <description>&lt;p&gt;The support call starts the same way every time: "it says connected but the picture is stuck." &lt;code&gt;pc.iceConnectionState&lt;/code&gt; is &lt;code&gt;connected&lt;/code&gt;. The signaling socket is open. Nothing in the logs is an error.&lt;/p&gt;

&lt;p&gt;That is the failure this article is about, because the fix is not a config value. It is a recovery ladder: three tiers of escalating repair, each with a different cost, and a detection method that does not trust the connection state at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The whole thing in one line: &lt;code&gt;connected&lt;/code&gt; describes the network path, not the picture, so a frozen tile and a healthy ICE state are entirely compatible.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tier 0: detect on frames, never on ICE state
&lt;/h2&gt;

&lt;p&gt;ICE state tells you whether a candidate pair was selected. It does not tell you whether frames are still arriving. A path that stops carrying media can sit in &lt;code&gt;connected&lt;/code&gt; indefinitely, and a UI that renders a status dot from ICE state will happily show green over a black rectangle.&lt;/p&gt;

&lt;p&gt;Ask the video element instead. &lt;code&gt;requestVideoFrameCallback&lt;/code&gt; fires once per delivered frame, which makes it the cheapest possible liveness probe — no decoding, no stats parsing, and it is exactly the thing that stops happening when the picture freezes:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestVideoFrameCallback&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestVideoFrameCallback&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;tick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;lastFrame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestVideoFrameCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frozen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;video&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;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;performance&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;lastFrame&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// surface it; do not silently wait&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two seconds is a deliberate choice: long enough that a normal keyframe interval or a momentary loss spike does not trip it, short enough that a user notices the recovery instead of giving up.&lt;/p&gt;

&lt;p&gt;This is also the reason &lt;code&gt;getStats()&lt;/code&gt; is a second opinion rather than the primary signal. It is the right tool for the &lt;em&gt;cause&lt;/em&gt; — &lt;code&gt;framesReceived&lt;/code&gt; climbing while &lt;code&gt;framesDecoded&lt;/code&gt; stays flat means codec or keyframe, a climbing &lt;code&gt;packetsLost&lt;/code&gt; means bandwidth — but polling it for liveness costs more and reports later than a frame callback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tier 1: an ICE restart, with a deadline
&lt;/h2&gt;

&lt;p&gt;A restart is the cheapest repair, because it re-gathers candidates without tearing down the PeerConnection, the tracks, or the application state:&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;function&lt;/span&gt; &lt;span class="nf"&gt;tryRestart&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;restarting&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;restarting&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;sig&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="s1"&gt;restart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&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;restarting&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pc&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;iceConnectionState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;fullRejoin&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;5000&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;Two details in those nine lines are the whole lesson.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;restarting&lt;/code&gt; flag is what keeps a restart from stacking on top of itself while a previous attempt is still in flight. Without it, a 5-second timer that fires every second produces a restart storm, and the storm is worse than the original fault because now the server is doing new offers for a client that is still falling over.&lt;/p&gt;

&lt;p&gt;The 5-second deadline is what stops the ladder from looping forever at tier 1. A restart fixes a &lt;em&gt;path&lt;/em&gt; that changed — a phone that moved from Wi-Fi to cellular, a NAT mapping that expired, a route that shifted. It cannot fix a remote peer that is gone, and if you do not escalate, a client whose publisher has disappeared will retry restarts until the tab is closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tier 2: the full rejoin, and why the old PeerConnection must die
&lt;/h2&gt;

&lt;p&gt;When the server rebuilds its side — the publisher went away, or republished as a new session — the next offer is not a continuation of the session you were in. It is a new one. Reusing the old PeerConnection at that point produces the worst possible outcome: a connection that negotiates successfully, reports &lt;code&gt;connected&lt;/code&gt;, and carries nothing, because the transceivers and the media state belong to a session the server has already discarded.&lt;/p&gt;

&lt;p&gt;So the second tier is an explicit teardown, then a fresh socket:&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;function&lt;/span&gt; &lt;span class="nf"&gt;teardown&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;pc&lt;/span&gt;&lt;span class="p"&gt;)&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;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nx"&gt;pc&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="nx"&gt;video&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fullRejoin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;teardown&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reconnect&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;Closing the PeerConnection is not cosmetic. It releases ICE agents and transceivers, and it makes the next &lt;code&gt;createOffer&lt;/code&gt; a genuinely new negotiation instead of a delta against stale state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signaling layer needs two guards of its own
&lt;/h2&gt;

&lt;p&gt;The ladder above is client-side recovery, but it depends on the socket underneath it behaving. Two guards matter, and both are absent from naive reconnect logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An epoch counter.&lt;/strong&gt; A socket that closes &lt;em&gt;after&lt;/em&gt; a newer socket has already connected would, without a guard, trigger a second reconnect and you end up with two live sockets and duplicated messages. Increment an epoch on every connect and let the handlers from stale sockets return early:&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;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;epoch&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;myEpoch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;epoch&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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;onclose&lt;/span&gt; &lt;span class="o"&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;myEpoch&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;epoch&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="c1"&gt;// a newer socket already owns the state&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;closedByUser&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retry&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;&lt;strong&gt;A send queue for the connecting window.&lt;/strong&gt; The self-test path builds its offer during page load, before the socket has necessarily finished opening. A &lt;code&gt;send()&lt;/code&gt; that returns &lt;code&gt;false&lt;/code&gt; in that window silently drops the offer, and the symptom on the other side is "the server never answered" — a client bug that looks exactly like a server bug. Queue while &lt;code&gt;readyState === CONNECTING&lt;/code&gt; and flush on open.&lt;/p&gt;

&lt;p&gt;The retry itself is exponential with a ceiling, &lt;code&gt;min(500 * 2^n, 8000)&lt;/code&gt;, because a reconnecting client that hammers a server which is already struggling is a self-inflicted second outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What not to do
&lt;/h2&gt;

&lt;p&gt;Do not keep the session alive with a client-side heartbeat timer. Hidden tabs throttle timers, and the tab is hidden precisely when a technician has put the phone in a pocket. Keepalives belong on the server, over the socket, for the reasons in the earlier article on reverse proxies.&lt;/p&gt;

&lt;p&gt;Do not treat a restart as free and fire it on every hint of trouble. A restart re-gathers candidates and produces a new offer/answer; doing that on a loss spike turns a mild network blip into a visible glitch.&lt;/p&gt;

&lt;p&gt;And do not hide the ladder from the user. A frozen tile with no indication is what makes someone close the tab. "Reconnecting" for two seconds, followed by a picture, is a product that looks like it is working.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ladder, in order
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Detect on frames, not on &lt;code&gt;iceConnectionState&lt;/code&gt;. A 2-second frame gap is a freeze.&lt;/li&gt;
&lt;li&gt;Try an ICE restart first, guarded by an in-flight flag.&lt;/li&gt;
&lt;li&gt;Give the restart a hard deadline. If it is still &lt;code&gt;failed&lt;/code&gt;, escalate.&lt;/li&gt;
&lt;li&gt;Escalate to a full rejoin: close the PeerConnection, then reconnect fresh. A rebuilt server session needs a new negotiation.&lt;/li&gt;
&lt;li&gt;Guard the socket with an epoch counter and queue sends during &lt;code&gt;CONNECTING&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Back off exponentially to a ceiling, and keep keepalives on the server.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I built this ladder into the browser clients of a self-hosted WebRTC kit, together with the diagnostics panel that shows the selected candidate path, the freeze counter and the per-leg stats, so each tier can be watched failing and recovering on a real network instead of being taken on faith:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lowlatencykit.com/" rel="noopener noreferrer"&gt;https://lowlatencykit.com/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The remote field-assist write-up, which walks the four network failure layers this ladder is designed to survive, is here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lowlatencykit.com/guides/remote-field-assist-mobile-webrtc/" rel="noopener noreferrer"&gt;https://lowlatencykit.com/guides/remote-field-assist-mobile-webrtc/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>javascript</category>
      <category>go</category>
      <category>webdev</category>
    </item>
    <item>
      <title>TURN returns 401 forever: the one config line nobody reads</title>
      <dc:creator>AiLaoHuYu</dc:creator>
      <pubDate>Tue, 13 Oct 2026 08:38:44 +0000</pubDate>
      <link>https://dev.to/ailaohuyu/turn-returns-401-forever-the-one-config-line-nobody-reads-11e9</link>
      <guid>https://dev.to/ailaohuyu/turn-returns-401-forever-the-one-config-line-nobody-reads-11e9</guid>
      <description>&lt;p&gt;A TURN server that answers every request with &lt;code&gt;401 Unauthorized&lt;/code&gt; looks like a credentials problem. Most of the time it is a missing &lt;code&gt;realm&lt;/code&gt;, and the reason it is hard to find is that the startup log tells you once and then never mentions it again.&lt;/p&gt;

&lt;p&gt;The pattern is always the same. The relay was configured with &lt;code&gt;use-auth-secret&lt;/code&gt;, the shared secret matches on both sides, the client sends a valid REST credential, and the log fills with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Default realm:
WARNING: ... you did not specify the default realm option
realm &amp;lt;&amp;gt; user &amp;lt;&amp;gt;: ... error 401: Unauthorized
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That empty &lt;code&gt;&amp;lt;&amp;gt;&lt;/code&gt; in &lt;code&gt;realm &amp;lt;&amp;gt; user &amp;lt;&amp;gt;&lt;/code&gt; is the whole bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The whole thing in one line: &lt;code&gt;use-auth-secret&lt;/code&gt; still needs a realm, and coturn will not remind you twice.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an empty realm breaks authentication
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;use-auth-secret&lt;/code&gt; is the long-term credential mechanism with a shared secret. The client is expected to be challenged once and then retry with a proper credential — a first &lt;code&gt;401&lt;/code&gt; is normal and healthy.&lt;/p&gt;

&lt;p&gt;What is not normal is the stream. The challenge carries the realm, the client hashes &lt;code&gt;username:realm:password&lt;/code&gt; for the REST scheme, and if the realm is empty the two sides are not answering the same question. The client retries, gets challenged with the same nothing, retries again, and eventually dies with a client-side &lt;code&gt;701&lt;/code&gt; timeout. From the browser it looks like the relay simply refuses you.&lt;/p&gt;

&lt;p&gt;Fix it with one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;realm&lt;/span&gt;=&lt;span class="n"&gt;turn&lt;/span&gt;.&lt;span class="n"&gt;example&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A normal stable hostname is the right choice. If the server is only reachable by IP, use that IP consistently. A hostname pays off later when you add TLS certificates, and it keeps the realm readable in a packet capture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The startup log is the actual debugging tool
&lt;/h2&gt;

&lt;p&gt;This is the part worth internalising: read the ten lines before the first client request, not the hundred identical 401s after it.&lt;/p&gt;

&lt;p&gt;A misspelled option, a wrong &lt;code&gt;-c&lt;/code&gt; path, or a config file coturn cannot read all produce the same symptom — your edits do not exist. Start with an absolute path so there is no ambiguity about which file won:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;turnserver &lt;span class="nt"&gt;-c&lt;/span&gt; /root/turnserver.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then confirm the startup output prints a non-empty &lt;code&gt;Default realm&lt;/code&gt;. If it prints nothing, stop debugging credentials; nothing you do to the client will matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Realm is a deployment setting, not a knob
&lt;/h2&gt;

&lt;p&gt;Once it is set, treat it as fixed. Changing the realm changes the authentication context clients see, so every cached or pre-generated credential has to be retested after the change. This is the kind of setting that silently invalidates a working integration during an otherwise harmless config edit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the shared secret with coturn's exact bytes
&lt;/h2&gt;

&lt;p&gt;After the realm is in place, check the secret the same way coturn will. The password is &lt;code&gt;base64(HMAC-SHA1(secret, username))&lt;/code&gt; — not a direct HMAC of the password, and not an MD5 hash:&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="nv"&gt;SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'mysecret'&lt;/span&gt;
&lt;span class="nv"&gt;EXP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;3600&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="nv"&gt;USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXP&lt;/span&gt;&lt;span class="s2"&gt;:bob"&lt;/span&gt;
&lt;span class="nv"&gt;PASS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | openssl dgst &lt;span class="nt"&gt;-binary&lt;/span&gt; &lt;span class="nt"&gt;-sha1&lt;/span&gt; &lt;span class="nt"&gt;-hmac&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | openssl &lt;span class="nb"&gt;base64&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'username=%s\npassword=%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PASS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two traps live in that command. A trailing newline or a set of quotes that got included in the secret value will produce a credential that is wrong by one or two bytes and looks perfect. And an expiry in the past produces a credential that is well-formed and rejected.&lt;/p&gt;

&lt;p&gt;Paste the result into the Trickle ICE page against:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;turn:12.34.567.89:3478?transport=udp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The second config gap that looks identical
&lt;/h2&gt;

&lt;p&gt;If the realm is correct and credentials verify but the client still cannot relay, check &lt;code&gt;external-ip&lt;/code&gt;. When the machine has a private interface and the public address is attached by the provider's NAT, the relayed candidate must contain the public address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;external&lt;/span&gt;-&lt;span class="n"&gt;ip&lt;/span&gt;=&lt;span class="m"&gt;12&lt;/span&gt;.&lt;span class="m"&gt;34&lt;/span&gt;.&lt;span class="m"&gt;567&lt;/span&gt;.&lt;span class="m"&gt;89&lt;/span&gt;/&lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;10.0.0.4&lt;/code&gt; with the actual local address coturn listens on. And open the UDP relay range in both places — the provider firewall and the host firewall. A control port that answers while the relay range is closed gives you a &lt;code&gt;srflx&lt;/code&gt; candidate and no &lt;code&gt;relay&lt;/code&gt; candidate, which the browser reports as a generic failure.&lt;/p&gt;

&lt;p&gt;For a server-side check that removes the browser entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;turnutils_uclient &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PASS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 3478 &lt;span class="se"&gt;\&lt;/span&gt;
  12.34.567.89
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that allocates, the server is fine and the problem is in the client's credential or the network path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check order that saves time
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Read the startup log for &lt;code&gt;Default realm&lt;/code&gt;. Empty means stop and fix the config.&lt;/li&gt;
&lt;li&gt;Start coturn with an absolute config path and confirm your file is the one loaded.&lt;/li&gt;
&lt;li&gt;Expect one &lt;code&gt;401&lt;/code&gt;, then an allocation. An endless 401 stream with a client &lt;code&gt;701&lt;/code&gt; is the realm failure, not a wrong secret.&lt;/li&gt;
&lt;li&gt;Generate the REST credential with coturn's exact algorithm and check for stray whitespace or quotes.&lt;/li&gt;
&lt;li&gt;Verify &lt;code&gt;external-ip&lt;/code&gt; whenever the host interface is private.&lt;/li&gt;
&lt;li&gt;Open the relay port range in both firewalls, not just the control port.&lt;/li&gt;
&lt;li&gt;Prove it with &lt;code&gt;turnutils_uclient&lt;/code&gt; so you are not debugging TLS, browsers and JavaScript at the same time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is complicated. All of it is invisible until you know that the one line above the 401s is the line that matters.&lt;/p&gt;

&lt;p&gt;I packaged a working self-hosted WebRTC stack — the Go signaling server and SFU, the browser publisher and viewer, the ICE restart and rejoin chain, the coturn configuration and a two-probe relay test that separates "port unreachable" from "allocation refused" — as a source kit you run on your own server:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lowlatencykit.com/" rel="noopener noreferrer"&gt;https://lowlatencykit.com/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you only want to know whether your own TURN server allocates, that test page is free and needs no account:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lowlatencykit.com/test/turn/" rel="noopener noreferrer"&gt;https://lowlatencykit.com/test/turn/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>networking</category>
      <category>go</category>
      <category>devops</category>
    </item>
    <item>
      <title>Audio works, video is black: debugging a layered WebRTC SFU in Go</title>
      <dc:creator>AiLaoHuYu</dc:creator>
      <pubDate>Tue, 13 Oct 2026 08:29:25 +0000</pubDate>
      <link>https://dev.to/ailaohuyu/audio-works-video-is-black-debugging-a-layered-webrtc-sfu-in-go-1gn3</link>
      <guid>https://dev.to/ailaohuyu/audio-works-video-is-black-debugging-a-layered-webrtc-sfu-in-go-1gn3</guid>
      <description>&lt;p&gt;Audio connects instantly, the viewer hears everything, and the video tile stays black. The publisher side looks fine. &lt;code&gt;ReplaceTrack&lt;/code&gt; returns &lt;code&gt;nil&lt;/code&gt;, which the code logs as success.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;nil&lt;/code&gt; is where most of these debugging sessions go wrong. &lt;code&gt;ReplaceTrack&lt;/code&gt; returning &lt;code&gt;nil&lt;/code&gt; means one thing: the track was bound to a sender that already existed. It does not mean the negotiated SDP allows that m-line to send, it does not mean both legs agreed on a codec, and it does not mean a single video RTP packet ever reached the downstream leg. Three separate failures that produce the same black rectangle.&lt;/p&gt;

&lt;p&gt;This is the order that separates them, cheapest check first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The whole thing in one line: audio and video travel the same connection but not the same negotiated path, so a working audio leg proves nothing about video.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  First, read the negotiated direction, not the code
&lt;/h2&gt;

&lt;p&gt;Direction in SDP is not a preference. It is the answer both sides ended up with, and it is what controls the RTP path.&lt;/p&gt;

&lt;p&gt;The sending leg's video m-section must be &lt;code&gt;sendonly&lt;/code&gt; or &lt;code&gt;sendrecv&lt;/code&gt;. The receiving leg must be &lt;code&gt;recvonly&lt;/code&gt; or &lt;code&gt;sendrecv&lt;/code&gt;. &lt;code&gt;ReplaceTrack&lt;/code&gt; does not renegotiate direction. You can add a track to a sender that the remote side accepted as &lt;code&gt;inactive&lt;/code&gt; and get a perfectly fine &lt;code&gt;nil&lt;/code&gt; back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;m=video ...
a=mid:&amp;lt;same mid on both sides&amp;gt;
a=sendrecv | a=sendonly   # sender side
a=recvonly | a=sendrecv   # receiver side
a=rtpmap:&amp;lt;pt&amp;gt; &amp;lt;matching video codec&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the video m-line has no usable &lt;code&gt;a=ssrc&lt;/code&gt; from the sender, or the remote direction is &lt;code&gt;recvonly&lt;/code&gt;/&lt;code&gt;inactive&lt;/code&gt;, stop. That is a negotiation fault, and no amount of buffering or async handling will fix it. Setting direction on one side only is not enough — the negotiated answer is the one that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Second, the codec has to match across the boundary
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;TrackLocalStaticRTP&lt;/code&gt; rewrites the SSRC and the payload type when it writes into a sender. It does not transcode, and it does not rewrite codec parameters.&lt;/p&gt;

&lt;p&gt;So forwarding H.264 between two legs that negotiated different profiles, or writing VP8 packets into a sender that negotiated H.264, gives you a track object that exists, accepts writes, and never decodes. The mime type looking similar is not the same as the codec being compatible.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;remoteTrack.Codec().RTPCodecCapability&lt;/code&gt; on the inbound side and the negotiated codec on the outbound sender are not interchangeable just because both say &lt;code&gt;video/H264&lt;/code&gt;. Pin the same codec on both legs, or transcode at the boundary. Pick one deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Third, find out whether RTP arrived at all
&lt;/h2&gt;

&lt;p&gt;Before touching the forwarding loop, get evidence. Two log lines split the problem in half: packets that never arrived, versus packets that arrived and were not used.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// After negotiation, before the forwarding loop starts.&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;subSFU&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;videoRTPTransceiver&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sender&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetParameters&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Encodings&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sub video sender ssrc=%d pt=%d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SSRC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PayloadType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// In the master forwarding loop.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;localTrack&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteRTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packet&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sub video write failed: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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 receiving side, log the track handed to &lt;code&gt;OnTrack&lt;/code&gt;, then read &lt;code&gt;pc.GetStats()&lt;/code&gt; for the inbound RTP stream and check whether &lt;code&gt;packetsReceived&lt;/code&gt; moves.&lt;/p&gt;

&lt;p&gt;If the downstream leg never receives the first video packet, the fault is in negotiation or sending. If it receives packets and the decoder never produces frames, the fault is codec compatibility or keyframes. Those are different bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fourth, a forwarding loop that hides its own death
&lt;/h2&gt;

&lt;p&gt;The classic version of this loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;packet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;remoteTrack&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadRTP&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"upstream video read failed: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;localTrack&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteRTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packet&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sub video write failed: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both exits matter. A single failed binding must not be mistaken for a successful fan-out, and a read that returns an error must not kill the goroutine silently — the symptom of a dead forwarding goroutine is exactly "audio works, video disappeared" on the leg it was feeding.&lt;/p&gt;

&lt;p&gt;One more trap in the same loop: &lt;code&gt;ReadRTP()&lt;/code&gt; allocates to Pion's receive MTU. If you keep a fixed byte slice and reuse it, handle &lt;code&gt;io.ErrShortBuffer&lt;/code&gt; explicitly instead of letting the goroutine exit. The failure looks identical to a network problem and costs an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fifth, flowing RTP with a black tile is a keyframe problem
&lt;/h2&gt;

&lt;p&gt;Packets can arrive continuously while the viewer stays black, because the first decodable frame never arrived. Send a PLI on the inbound video receiver and then confirm that &lt;code&gt;framesReceived&lt;/code&gt; and &lt;code&gt;framesDecoded&lt;/code&gt; actually advance in &lt;code&gt;GetStats()&lt;/code&gt;. If &lt;code&gt;framesReceived&lt;/code&gt; climbs and &lt;code&gt;framesDecoded&lt;/code&gt; stays flat, you are looking at codec parameters or a missing keyframe, not transport.&lt;/p&gt;

&lt;p&gt;Keep that check separate from the previous one. Transport and decode fail with the same pixels on screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern that avoids the whole renegotiation dance
&lt;/h2&gt;

&lt;p&gt;Instead of adding video late and hoping the negotiation cooperates: create the video transceiver with a placeholder &lt;code&gt;TrackLocalStaticRTP&lt;/code&gt; before the very first offer/answer, negotiate it as &lt;code&gt;sendrecv&lt;/code&gt;, and only then swap in the real upstream track once the initial negotiation is stable.&lt;/p&gt;

&lt;p&gt;The placeholder costs one unused m-line and removes an entire class of "the sender exists but the direction does not allow media" failures. Each downstream leg gets its own binding of the same local track, and every write is checked — one failed binding must not be mistaken for a successful fan-out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check order that saves time
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Read the negotiated direction on both legs. No &lt;code&gt;sendrecv&lt;/code&gt;/&lt;code&gt;sendonly&lt;/code&gt; on the sender means it is a negotiation bug.&lt;/li&gt;
&lt;li&gt;Confirm the same codec on both legs. Similar mime types are not proof.&lt;/li&gt;
&lt;li&gt;Log the sender SSRC and payload type, then check &lt;code&gt;packetsReceived&lt;/code&gt; on the inbound leg.&lt;/li&gt;
&lt;li&gt;Handle every error in the forwarding loop; a dead goroutine looks like a network fault.&lt;/li&gt;
&lt;li&gt;If packets flow but frames do not decode, request a keyframe and compare &lt;code&gt;framesReceived&lt;/code&gt; with &lt;code&gt;framesDecoded&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Prefer a placeholder transceiver negotiated up front over adding video mid-session.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is exotic. All of it is invisible when the publisher and the viewer happen to agree by accident, which is why demos pass and production does not.&lt;/p&gt;

&lt;p&gt;I packaged the working version of all of this — the Go signaling server and SFU, the browser publisher and viewer, the ICE restart and rejoin chain, and the diagnostics panel that shows the selected candidate path and the per-leg counters — as a self-hosted source kit, so you can prove each layer on your own server instead of inferring it from a black box:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lowlatencykit.com/" rel="noopener noreferrer"&gt;https://lowlatencykit.com/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you only want to know whether your own TURN server allocates, the relay test page is free and needs no account:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lowlatencykit.com/test/turn/" rel="noopener noreferrer"&gt;https://lowlatencykit.com/test/turn/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>go</category>
      <category>video</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Remote video support that survives a technician's phone network</title>
      <dc:creator>AiLaoHuYu</dc:creator>
      <pubDate>Tue, 13 Oct 2026 05:46:50 +0000</pubDate>
      <link>https://dev.to/ailaohuyu/remote-video-support-that-survives-a-technicians-phone-network-19op</link>
      <guid>https://dev.to/ailaohuyu/remote-video-support-that-survives-a-technicians-phone-network-19op</guid>
      <description>&lt;p&gt;Remote visual assistance demos well on office Wi-Fi and fails the first time a technician climbs into a van. I have watched this happen with a working demo and a confused buyer, and the failure is almost never the codec. It is the network underneath it: the phone changes address mid-session, it sits behind a carrier NAT that no direct candidate can reach, and the server does not know its own public address.&lt;/p&gt;

&lt;p&gt;This is the write-up I wanted when I started. Symptoms first, because that is what you search for when a field session has gone dark and the customer is waiting.&lt;/p&gt;

&lt;p&gt;The whole thing in one line: &lt;strong&gt;field video fails in four different layers, and they look identical from the UI.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  It works in the office and fails on cellular
&lt;/h2&gt;

&lt;p&gt;ICE finds a path by gathering candidates. A &lt;code&gt;host&lt;/code&gt; candidate is the device's own address. An &lt;code&gt;srflx&lt;/code&gt; candidate is the address a STUN server saw after NAT. A &lt;code&gt;relay&lt;/code&gt; candidate is an address on a TURN server that both sides send to when nothing direct works.&lt;/p&gt;

&lt;p&gt;Consumer Wi-Fi is usually cone NAT, so two peers discover each other with STUN alone and the session connects with no infrastructure at all. A phone on cellular is frequently behind a carrier-grade NAT whose mapping is not reusable. The &lt;code&gt;srflx&lt;/code&gt; candidate is still gathered, still looks valid, and every packet sent to it is dropped, because the mapping was opened for a different destination.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; on Wi-Fi the connection establishes in under a second; on cellular it never leaves &lt;code&gt;checking&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to look at:&lt;/strong&gt; log the selected candidate pair. If it is never &lt;code&gt;relay&lt;/code&gt; when the call fails, you need TURN before you debug anything else.&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="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;oniceconnectionstatechange&lt;/span&gt; &lt;span class="o"&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="k"&gt;if &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;iceConnectionState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&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;// which pair did we actually end up on?&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;getStats&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&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;r&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="s1"&gt;candidate-pair&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="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&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;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;failed pair&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localCandidateId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remoteCandidateId&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The relay port answers but the allocation is refused
&lt;/h2&gt;

&lt;p&gt;"No video through the relay" has two causes that are indistinguishable from the browser: the UDP port is unreachable, or the port is fine and the relay declined the allocation. Different fixes, so test them separately.&lt;/p&gt;

&lt;p&gt;First, a STUN probe against the same host and port as your TURN server. If it returns an &lt;code&gt;srflx&lt;/code&gt; candidate, the port is open and something is listening. Then a real allocation with the real credentials. A &lt;code&gt;relay&lt;/code&gt; candidate coming back means TURN works and the problem is elsewhere. A refusal with the port open means credentials or the allocation range — usually a 401 in the coturn log, or a relay port range that was never opened even though the control port was.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# coturn: open the control port AND the relay range
&lt;/span&gt;&lt;span class="n"&gt;listening&lt;/span&gt;-&lt;span class="n"&gt;port&lt;/span&gt;=&lt;span class="m"&gt;3478&lt;/span&gt;
&lt;span class="n"&gt;min&lt;/span&gt;-&lt;span class="n"&gt;port&lt;/span&gt;=&lt;span class="m"&gt;49152&lt;/span&gt;
&lt;span class="n"&gt;max&lt;/span&gt;-&lt;span class="n"&gt;port&lt;/span&gt;=&lt;span class="m"&gt;65535&lt;/span&gt;

&lt;span class="c"&gt;# a cloud VM must be told its own public address
&lt;/span&gt;&lt;span class="n"&gt;external&lt;/span&gt;-&lt;span class="n"&gt;ip&lt;/span&gt;=&lt;span class="m"&gt;203&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;113&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relay range is the part people forget. The control port on 3478 answers, the credentials are perfect, and the allocation still fails because the media ports coturn wanted are closed in the security group. This is the trap that makes a TURN config look correct while every cellular session dies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Video freezes when the technician walks to the van
&lt;/h2&gt;

&lt;p&gt;A WebRTC transport is bound to a 5-tuple: source address, source port, destination address, destination port, protocol. When the phone moves from Wi-Fi to cellular it gets a new source address and the 5-tuple the session was using is gone. The old candidate pair cannot be revived. On screen this is a freeze with no error, because the connection object has not given up yet.&lt;/p&gt;

&lt;p&gt;The recovery is an ICE restart, which renegotiates connectivity on the existing peer connection instead of tearing the session down. A restart is not always enough after a long outage or a carrier change, so the practical chain is: ICE restart first, full rejoin as fallback, jittered backoff between attempts so a fleet of devices does not hammer the server at once when a tower comes back up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not trust the connection state alone.&lt;/strong&gt; A connection can read &lt;code&gt;connected&lt;/code&gt; while frames have stopped. Watch the frame callbacks instead, and treat a missing frame as frozen even while ICE still says connected:&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;let&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&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="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestVideoFrameCallback&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;tick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&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="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestVideoFrameCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;performance&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;last&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;restartChain&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// stalled, not disconnected&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  ICE connects but no frame ever decodes
&lt;/h2&gt;

&lt;p&gt;On a cloud VM the network interface usually has a private address and the public address is attached by the provider's NAT. If the server gathers candidates from its own interface it advertises something like &lt;code&gt;172.16.x.x&lt;/code&gt; in SDP, and the remote browser tries a private address it can never reach. ICE can even report connected on a local path while the media never leaves the host.&lt;/p&gt;

&lt;p&gt;Tell the server its public address, or force the relay path. In coturn that is &lt;code&gt;external-ip&lt;/code&gt;; in a Go server it is a &lt;code&gt;-public-ip&lt;/code&gt; flag or an ICE transport policy of &lt;code&gt;relay&lt;/code&gt;. If a session connects instantly between two machines on the same office network and fails from outside, check this first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tab says connected but nothing arrives
&lt;/h2&gt;

&lt;p&gt;Signaling runs over a WebSocket and reverse proxies reap idle WebSockets, often around 60 seconds. The socket closes, the application does not notice, and the UI keeps saying connected because nothing told it otherwise.&lt;/p&gt;

&lt;p&gt;The fix is a protocol-level ping &lt;strong&gt;from the server&lt;/strong&gt;, not a JavaScript heartbeat. Background tabs throttle timers aggressively, so a client timer that fires every 20 seconds in the foreground can fire far less often when the tab is hidden — which is exactly when a technician has put the phone in a pocket.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check order that saves time
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Log the selected candidate pair. Never &lt;code&gt;relay&lt;/code&gt; on a cellular failure means you need TURN first.&lt;/li&gt;
&lt;li&gt;Probe the relay in two steps: STUN on the control port, then a real allocation. It separates "port unreachable" from "allocation refused".&lt;/li&gt;
&lt;li&gt;Confirm the relay media port range is open, not just the control port.&lt;/li&gt;
&lt;li&gt;Set the public address explicitly on any host whose interface is private.&lt;/li&gt;
&lt;li&gt;Trigger an ICE restart on a freeze and fall back to a full rejoin, with jittered backoff.&lt;/li&gt;
&lt;li&gt;Keep the signaling socket alive from the server, not from a client timer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is exotic once it is written down, and all of it is invisible in a demo on good Wi-Fi. The failure modes only show up in the field, which is where a field-assist product actually runs.&lt;/p&gt;

&lt;p&gt;I packaged the working version of all of this — the Go signaling server and SFU, the browser publisher and viewer, the ICE restart and rejoin chain, the diagnostics panel that shows the selected candidate path, the coturn config and the two-probe relay test — as a self-hosted source kit, so you can prove each layer on your own server instead of inferring it from a black box:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lowlatencykit.com/field-assist/" rel="noopener noreferrer"&gt;https://lowlatencykit.com/field-assist/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The relay test page is free and needs no account, if you just want to check whether your own TURN server allocates:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lowlatencykit.com/test/turn/" rel="noopener noreferrer"&gt;https://lowlatencykit.com/test/turn/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>networking</category>
      <category>webdev</category>
      <category>go</category>
    </item>
    <item>
      <title>What breaks when you self-host a TURN server (coturn — symptom, cause, fix)</title>
      <dc:creator>AiLaoHuYu</dc:creator>
      <pubDate>Sun, 20 Sep 2026 03:53:34 +0000</pubDate>
      <link>https://dev.to/ailaohuyu/what-breaks-when-you-self-host-a-turn-server-coturn-symptom-cause-fix-g10</link>
      <guid>https://dev.to/ailaohuyu/what-breaks-when-you-self-host-a-turn-server-coturn-symptom-cause-fix-g10</guid>
      <description>&lt;p&gt;The first article in this series was the traps inside the code. The second was the layer underneath — the candidates that lie, the UDP firewall nobody looks at, the reverse proxy that reaps an idle socket. This one is about the piece people reach for when those two are not enough: the relay.&lt;/p&gt;

&lt;p&gt;TURN is the part of WebRTC that gets configured last, in a hurry, by copying a config off a blog post. That is exactly how you end up with a relay that passes every "is it running?" check and still produces no video.&lt;/p&gt;

&lt;p&gt;The failures below are all coturn behaviours you can read in the config and the log. What makes them expensive is not that they are subtle — it is that most of them fail without an error message anywhere near where you are looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: is TURN even your problem?
&lt;/h2&gt;

&lt;p&gt;It often is not. Adding a relay to a system whose real fault is elsewhere hides the fault and makes the next debugging session worse.&lt;/p&gt;

&lt;p&gt;Three checks, in this order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look at which candidate type won.&lt;/strong&gt; In &lt;code&gt;chrome://webrtc-internals&lt;/code&gt; (or &lt;code&gt;about:webrtc&lt;/code&gt; in Firefox), find the selected candidate pair and read the local candidate's type. If it says &lt;code&gt;relay&lt;/code&gt;, TURN is in the path and working, and your problem is somewhere else entirely. If it says &lt;code&gt;srflx&lt;/code&gt; or &lt;code&gt;host&lt;/code&gt; while the connection fails, TURN is not being used at all — which is a different bug from TURN being broken, and it lives in your ICE configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Force the relay.&lt;/strong&gt; In a test page, build the peer connection with &lt;code&gt;iceTransportPolicy: 'relay'&lt;/code&gt;. Now TURN is the only permitted path. If this connects while your normal config does not, your relay is fine. If this also fails, you are actually debugging TURN.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the two failures from the last article first.&lt;/strong&gt; A server advertising an unroutable address and a closed UDP media range both produce the same visible symptom as a dead relay: signaling green, ICE stuck checking, then failed. They cost nothing to rule out and they are far more common than a broken coturn.&lt;/p&gt;

&lt;h2&gt;
  
  
  It says it started and is bound to nothing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; &lt;code&gt;systemctl start coturn&lt;/code&gt; returns success. &lt;code&gt;systemctl status coturn&lt;/code&gt; says active. Nothing works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; On Debian and Ubuntu the package ships &lt;code&gt;/etc/default/coturn&lt;/code&gt; with the enable switch commented out, and the start path honours that file. Depending on the version and how the package was installed, that gate is either the init script or a unit that wraps it — and either way you get a clean exit with no error message. A service that declined to start and a service that started and could not bind look identical from &lt;code&gt;systemctl&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is also where a config file mismatch hides: coturn reads &lt;code&gt;/etc/turnserver.conf&lt;/code&gt; by default, but if something passes &lt;code&gt;-c&lt;/code&gt; to a different path, your edits are being ignored and nothing tells you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check.&lt;/strong&gt; Do not ask whether the service is running. Ask whether anything is bound.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ss &lt;span class="nt"&gt;-lunp&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'3478|5349'&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; coturn &lt;span class="nt"&gt;-n&lt;/span&gt; 50 &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the ports are not in the &lt;code&gt;ss&lt;/code&gt; output, it is not running in any sense that matters. Note that &lt;code&gt;curl&lt;/code&gt; is useless here — TURN is not HTTP, so an HTTP request to 3478 will connect and then produce something unhelpful. It proves nothing either way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; &lt;code&gt;TURNSERVER_ENABLED=1&lt;/code&gt; in &lt;code&gt;/etc/default/coturn&lt;/code&gt;, restart, re-run the check. If it is still not bound, you are now looking at a real startup error and the log will have it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The relay address points inward
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; The allocation succeeds. The client gets a relayed candidate. The candidate is a &lt;code&gt;10.x&lt;/code&gt; or &lt;code&gt;172.16–31.x&lt;/code&gt; address. ICE never gets past checking, from the outside it looks like the relay is broken, and on the server everything is green.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; A cloud VM's network interface has a private address and the public IP exists in the provider's NAT layer in front of it. The machine has no idea what its public address is. coturn advertises the address it is bound to, so it advertises the private one, and nobody outside can route to it.&lt;/p&gt;

&lt;p&gt;This is the same class of bug as the host candidates in the previous article, one layer up: the service is telling the truth about an interface that is a lie.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Both halves of the mapping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;external&lt;/span&gt;-&lt;span class="n"&gt;ip&lt;/span&gt;=&lt;span class="m"&gt;203&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;113&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;/&lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Public first, private second, slash between them. Setting only the public address is a common half-fix that works until coturn needs to bind locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify.&lt;/strong&gt; Re-read the relayed candidate. It should now be the public address, and it should be reachable — which is a different claim, and the next section is why.&lt;/p&gt;

&lt;h2&gt;
  
  
  You opened the control port and called it done
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; Allocation succeeds. The relayed candidate is correct and public. Media still never flows. Or: it works for one client and not another, with nothing different about them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; 3478 is the control port. The media does not go over it. coturn allocates a relay port per client from a range — &lt;code&gt;min-port&lt;/code&gt; to &lt;code&gt;max-port&lt;/code&gt;, default 49152 to 65535 — and the actual RTP flows there, over &lt;strong&gt;UDP&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This one is worse than the others because the successful allocation proves your firewall work is &lt;em&gt;partly&lt;/em&gt; right, which is exactly the wrong amount of right. You conclude the network is fine and go looking in your application code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Open the relay range in &lt;strong&gt;both&lt;/strong&gt; firewalls: the provider's security group and the host's own ufw/iptables. On most cloud providers the security group is the one that actually decides whether a packet arrives and the host firewall is a red herring; on a VPS with a full iptables ruleset it is the reverse. Open UDP. Opening only TCP gets you nothing, because browsers will use UDP for TURN whenever it is available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not narrow the range "for security."&lt;/strong&gt; This is the trap hidden in a reasonable-sounding hardening step. Each client using the relay takes a port out of the range, and one page can open more than one allocation. A forty-port range is a lab with a handful of users and nothing else, and the way you find out is under load, months later, as an intermittent failure that does not reproduce.&lt;/p&gt;

&lt;p&gt;Keep the default range, and if you must narrow it, size it against the number of concurrent clients you expect rather than the number of streams you are picturing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nobody can authenticate, and only the log will say why
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; Every allocation is refused. The client retries, or gives up and reports ICE failure. The relay is bound, reachable, and simply will not talk to you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; coturn has two credential modes and they are alternatives, not layers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;lt-cred-mech&lt;/code&gt;&lt;/strong&gt; is static long-term credentials, with users created through &lt;code&gt;turnadmin&lt;/code&gt;. If you enable it and never create a user, every request is refused and there is nothing wrong with your configuration as such.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;use-auth-secret&lt;/code&gt;&lt;/strong&gt; with a &lt;code&gt;static-auth-secret&lt;/code&gt; is the time-limited REST scheme. The client generates a username of &lt;code&gt;&amp;lt;unix-expiry-timestamp&amp;gt;:&amp;lt;any-label&amp;gt;&lt;/code&gt; and a password of &lt;code&gt;base64(HMAC-SHA1(secret, username))&lt;/code&gt;. Every part of that has to be exact — the HMAC variant, the encoding, the units of the expiry. Get any of it slightly wrong and you get a refusal that looks exactly like a wrong password, because it is one.&lt;/p&gt;

&lt;p&gt;A config that sets neither is, in effect, an unauthenticated relay. That is not a neutral default: it is an open relay, someone will find it, and their traffic will be on your bill and your IP's reputation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Pick one mode, set it explicitly, and turn on &lt;code&gt;verbose&lt;/code&gt; so the log shows you the request instead of leaving you to infer it. Then generate the credential the way the server expects:&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;# username = &amp;lt;unix expiry&amp;gt;:&amp;lt;label&amp;gt;;  password = base64(hmac-sha1(secret, username))&lt;/span&gt;
&lt;span class="nv"&gt;EXP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;3600&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="nv"&gt;USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXP&lt;/span&gt;&lt;span class="s2"&gt;:demo"&lt;/span&gt;
&lt;span class="nv"&gt;PASS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | openssl dgst &lt;span class="nt"&gt;-binary&lt;/span&gt; &lt;span class="nt"&gt;-sha1&lt;/span&gt; &lt;span class="nt"&gt;-hmac&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | openssl &lt;span class="nb"&gt;base64&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'username=%s\npassword=%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PASS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Read the response code, not the symptom.&lt;/strong&gt; TURN tells you exactly what is wrong if you look at the code in the log:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;401&lt;/strong&gt; — the challenge. On a &lt;em&gt;first&lt;/em&gt; request this is normal and expected; the client is supposed to retry with credentials. Seeing 401s is not itself a fault, and treating it as one sends people down a long road.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;441&lt;/strong&gt; — wrong credentials. Your HMAC or your expiry is off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;438&lt;/strong&gt; — stale nonce. The client cached a nonce past its lifetime; a conforming client retries and you should not see it persist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;403&lt;/strong&gt; — forbidden, including a denied peer. See the blocklist section.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;486&lt;/strong&gt; — allocation quota reached. See the last section.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;coturn ships with &lt;code&gt;turnutils_uclient&lt;/code&gt;, which does a real allocation against your server and prints what came back. It is the fastest way to test the relay without a browser in the loop at all — check &lt;code&gt;turnutils_uclient -h&lt;/code&gt; for the current flags.&lt;/p&gt;

&lt;h2&gt;
  
  
  The certificate is valid and coturn still cannot read it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; &lt;code&gt;turn:&lt;/code&gt; on 3478 works. &lt;code&gt;turns:&lt;/code&gt; on 5349 does not. Or coturn fails to start with a message about a file that definitely exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; &lt;code&gt;turns:&lt;/code&gt; needs a certificate, coturn runs as its own unprivileged user, and Let's Encrypt's &lt;code&gt;/etc/letsencrypt/live/&lt;/code&gt; directory is symlinks into &lt;code&gt;archive/&lt;/code&gt; behind permissions that user does not have. The certificate is valid, your path is correct, and the process cannot open it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Give the turnserver user read access to the cert tree, or have the renewal hook copy the pair somewhere readable with the right ownership. Both work; the second survives a permissions change on the original.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the part worth more than the fix:&lt;/strong&gt; &lt;code&gt;turn:&lt;/code&gt; on 3478 does not need TLS at all. If what you need is UDP relay for browsers, you can leave &lt;code&gt;turns:&lt;/code&gt; out entirely and this whole failure mode stops existing.&lt;/p&gt;

&lt;p&gt;There is a second assumption worth killing while you are here: &lt;strong&gt;UDP TURN from an HTTPS page is not mixed content.&lt;/strong&gt; The browser will not block it, and you do not need &lt;code&gt;turns:&lt;/code&gt; to satisfy a security policy. It is a common reason people add a certificate they did not need and then spend an evening on permissions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The blocklist is doing exactly what it was designed to do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; Allocation succeeds, the candidate is right, the ports are open, and media still does not flow. The log mentions the peer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause.&lt;/strong&gt; coturn ships with &lt;code&gt;denied-peer-ip&lt;/code&gt; entries covering loopback, the private ranges, link-local, multicast and reserved space. This is deliberate and good: it is what stops your relay being used to reach things it should not reach.&lt;/p&gt;

&lt;p&gt;But if your peers legitimately live on a private network — a corporate deployment, an internal lab, anything over a VPN — the relay is correctly refusing to talk to them, and it is not going to tell you that it thinks it is protecting you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Allow the specific range you need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;allowed&lt;/span&gt;-&lt;span class="n"&gt;peer&lt;/span&gt;-&lt;span class="n"&gt;ip&lt;/span&gt;=&lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;20&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;-&lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;20&lt;/span&gt;.&lt;span class="m"&gt;255&lt;/span&gt;.&lt;span class="m"&gt;255&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Be precise about it. The default list is aggressive because a relay will happily connect to whatever it is told to, and you are the one removing that guard for a case where you actually want it removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  It works until it does not: quotas, TTLs and the bill
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; Clean in testing, degrades in production. Allocations start failing under load. Or the bandwidth graph shows relay traffic far larger than the number of relayed sessions should account for.&lt;/p&gt;

&lt;p&gt;Three things, in the order they usually turn out to be:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No quota.&lt;/strong&gt; coturn's defaults let a single client hold a great deal. &lt;code&gt;total-quota&lt;/code&gt; and &lt;code&gt;user-quota&lt;/code&gt; cap concurrent allocations, and &lt;code&gt;max-bps&lt;/code&gt; caps throughput per session. Without them, one misbehaving client is a capacity incident for everyone else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Relay traffic is real traffic.&lt;/strong&gt; Every relayed stream passes through your server, and you pay for it. This is worth watching as a &lt;em&gt;ratio&lt;/em&gt;, not a total: what fraction of your sessions needed relay candidates. If it is most of them, you do not have a TURN problem, you have a NAT-traversal problem that you are paying to route around. Treat a rising relay ratio as a defect in your ICE configuration, not as a reason to buy more bandwidth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Allocation lifetime.&lt;/strong&gt; Allocations are refreshed by the client. A client that stops refreshing — a throttled tab, a suspended mobile app — loses its allocation at the TTL, and the user experiences that as "it disconnects after a while," which sounds like a media problem and is not one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order I would check these in
&lt;/h2&gt;

&lt;p&gt;When a relayed session fails, in this order, cheapest first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ss -lunp | grep 3478&lt;/code&gt; — is anything actually bound, or did the service decline to start?&lt;/li&gt;
&lt;li&gt;Read the relayed candidate's address — public, or a private address nobody can route to?&lt;/li&gt;
&lt;li&gt;Confirm the relay UDP range is open in &lt;strong&gt;both&lt;/strong&gt; the provider firewall and the host firewall.&lt;/li&gt;
&lt;li&gt;Put &lt;code&gt;verbose&lt;/code&gt; in the config, reproduce, and read the TURN response code — 401 on the first request is normal, 441 and 486 are not.&lt;/li&gt;
&lt;li&gt;Force &lt;code&gt;iceTransportPolicy: 'relay'&lt;/code&gt; in a test page — does it connect when relay is the only permitted path?&lt;/li&gt;
&lt;li&gt;Watch the relay ratio — if most sessions relay, the relay is a symptom and not the cure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more, and it is the same one as last time because it is the one that keeps being true: test from a network that is not your development machine. Your laptop and your server are often close enough that a path which cannot work anywhere else still works for you. Wifi off, cellular on, open the page on a phone. That is a genuinely different NAT and a genuinely different path, and it is the only test that answers the question you are actually asking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The kit
&lt;/h2&gt;

&lt;p&gt;I put what I learned into a starter kit: the signaling server and SFU in Go (Pion v4), the browser publisher and viewer, the auto-reconnect chain, the diagnostics panel, deploy files for systemd, Caddy and Docker, and the pitfalls doc with symptom, cause and fix.&lt;/p&gt;

&lt;p&gt;There is a live demo you can open without an account, and a canvas self-test page that exercises the whole path and needs no camera: &lt;a href="https://lowlatencykit.com" rel="noopener noreferrer"&gt;https://lowlatencykit.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is paid (early access, Basic $99 / Pro $199 / Team $499, with an early-bird code on the landing page). This article is free, and so are the other two — that part is not a teaser, it is the actual content.&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>networking</category>
      <category>devops</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>WebRTC works on your laptop. Here is what breaks when you put it on a server.</title>
      <dc:creator>AiLaoHuYu</dc:creator>
      <pubDate>Sat, 19 Sep 2026 06:53:11 +0000</pubDate>
      <link>https://dev.to/ailaohuyu/webrtc-works-on-your-laptop-here-is-what-breaks-when-you-put-it-on-a-server-21lg</link>
      <guid>https://dev.to/ailaohuyu/webrtc-works-on-your-laptop-here-is-what-breaks-when-you-put-it-on-a-server-21lg</guid>
      <description>&lt;p&gt;The first article in this series covered the traps inside the code — the Pion v4 API changes, the SSRC rewrites, the renegotiation timing. This one is about the layer underneath it, where the failures look identical and have completely different causes.&lt;/p&gt;

&lt;p&gt;Everything below happened to me deploying a real-time streaming server to a cloud VM. None of it was a bug in my code, and all of it took longer to find than it should have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The split that explains most "it connects but there's no video"
&lt;/h2&gt;

&lt;p&gt;Signaling and media take different paths, and only one of them goes through the infrastructure you configured.&lt;/p&gt;

&lt;p&gt;Signaling is a WebSocket. It goes out on 443, it is plain TCP, your reverse proxy terminates TLS for it, your tunnel carries it, your certificate covers it, and every tool you already know how to debug applies to it.&lt;/p&gt;

&lt;p&gt;Media is RTP over UDP. It goes browser-to-server directly, carrying the actual audio and video. No part of your reverse proxy, your CDN or your tunnel touches it. Not by misconfiguration — by design. A WebSocket connection is not a suitable transport for a 30fps media stream and no proxy is going to turn into one.&lt;/p&gt;

&lt;p&gt;So the moment you see a session where the WebSocket is healthy and there is no picture, you are not looking at a bug in your web stack. You are looking at the media path, and the web stack is a red herring. That single distinction has saved me more time than any debugging tool.&lt;/p&gt;

&lt;p&gt;The candidate list in your SDP is the whole story of that second path. It is a list of "here is where I might be reachable", and most of the deployment failures below are about that list being wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: your ICE candidates are lies
&lt;/h2&gt;

&lt;p&gt;A cloud VM's network interface has a private address. Mine had a 172.31.x.x on the NIC; the public IP exists in the provider's NAT layer in front of it. The machine itself has no idea what its public address is.&lt;/p&gt;

&lt;p&gt;So when your server gathers ICE candidates the honest way, it reports what it can see: a private address nobody on the internet can route to. Signaling completes perfectly. ICE checks go out to an unreachable address. You get a connection that never connects, and nothing in any log says why.&lt;/p&gt;

&lt;p&gt;There are two ways out, and they are not equivalent:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use a STUN server.&lt;/strong&gt; The server asks a public STUN server "what address did this request appear to come from?" and advertises the answer. This is the standard solution and it works — right up until the NAT in front of your VM is the kind that assigns a different external port per destination, at which point the port you advertise is not the port that reaches you, and you are back to failing checks with no explanation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tell the server its public IP.&lt;/strong&gt; If your provider gives you a one-to-one mapping — public IP forwards directly to the instance, same ports — then you can skip the guessing entirely and configure the advertised address explicitly. The candidate your server hands out is then a statement of fact rather than an inference, and it holds regardless of what the NAT is doing.&lt;/p&gt;

&lt;p&gt;In Pion that is a setting engine call, made once before you build the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;se&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;webrtc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SettingEngine&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="n"&gt;se&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetNAT1To1IPs&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PublicIP&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;webrtc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ICECandidateTypeHost&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;api&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;webrtc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webrtc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithSettingEngine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;se&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second is what I ended up using, and the difference on the wire is not subtle. Before, checks that never completed. After, a usable pair with a 7ms round trip and a steady 30fps of decoded frames with zero freezes over a six-second test.&lt;/p&gt;

&lt;p&gt;If you take one thing from this section: the fact that your server is behind NAT is not a detail, it is a first-class configuration input you have to handle deliberately. Decide which of the two approaches fits your provider and set it. Do not assume the default gathering will figure it out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: the firewall that has nothing to do with your app
&lt;/h2&gt;

&lt;p&gt;Cloud providers give you two firewalls and only one of them is the one you think of as a firewall.&lt;/p&gt;

&lt;p&gt;The one on the machine — ufw, iptables, firewalld — is the one everybody checks. The one at the provider level, usually called a security group, sits in front of the instance and is what actually decides whether a packet arrives. It defaults to closed. On the VM I used, exactly one port was open out of the box: 22.&lt;/p&gt;

&lt;p&gt;Now the part that makes this expensive: your web setup gives you no warning at all. You open 443, you get a certificate, the site loads, the signaling connects, and you conclude the networking is done. Almost all of it is done. All of it that runs over TCP is done.&lt;/p&gt;

&lt;p&gt;Your media ports are UDP and they are in a range that is not 443, so nothing about a working HTTPS setup tells you anything about them.&lt;/p&gt;

&lt;p&gt;The symptom is the one from the previous section and it is maddening: signaling green, ICE stuck in "checking", then failed. Same visible outcome as wrong candidates, completely different fix.&lt;/p&gt;

&lt;p&gt;Here is how to tell trap 1 and trap 2 apart, because from the outside they look the same:&lt;/p&gt;

&lt;p&gt;Open the browser's WebRTC internals page (chrome://webrtc-internals in Chromium browsers; about:webrtc in Firefox) and look at the candidate list, not the connection state.&lt;/p&gt;

&lt;p&gt;If the only candidates you see are private addresses and no public one, you have trap 1 — your server never learned where it really is. If you see a sensible public candidate but the connectivity checks to it are the ones failing, you have trap 2 — the address is right and something is eating the packets.&lt;/p&gt;

&lt;p&gt;That is a two-minute check that tells you which of two very different afternoons you are about to have. Learn it before you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: your reverse proxy reaps the idle socket
&lt;/h2&gt;

&lt;p&gt;This one is a different failure shape and it is worse, because everything works first.&lt;/p&gt;

&lt;p&gt;The WebSocket carries signaling, and signaling is idle most of the time. Two peers agree on parameters, media flows, and then nothing needs to be said for a while. Your reverse proxy has an idle timeout, usually around 60 seconds, and an idle WebSocket looks exactly like an abandoned connection. It closes it.&lt;/p&gt;

&lt;p&gt;The tab, meanwhile, has no idea. From the user's perspective the stream is running, the UI says connected, and then something that needed signaling — a reconnection, a new viewer, a track change — goes into a socket that has been dead for a minute.&lt;/p&gt;

&lt;p&gt;The fix everyone reaches for first is a heartbeat from the browser: a small message every 30 seconds to keep the socket warm. Do not do this. Background tabs throttle timers, aggressively, and the tab that has been in the background for ten minutes is exactly the one whose heartbeat has stopped. You have built a keepalive that fails precisely when it is needed.&lt;/p&gt;

&lt;p&gt;Send the ping from the server instead, at the protocol level rather than the application level. The WebSocket spec defines ping and pong frames, browsers answer pings automatically, and that answer is not subject to the timer throttling that hits your JavaScript. Pair it with a read deadline on the server and you also get something a browser-side heartbeat never gives you: you find out about dead connections within a bounded time instead of discovering them when a room mysteriously gets stuck as busy.&lt;/p&gt;

&lt;p&gt;Making the server responsible for liveness also removes a category of bug entirely. There is no client-side code to get wrong, no clock to drift, no timer to be throttled.&lt;/p&gt;

&lt;p&gt;The shape of it in gorilla/websocket, with the browser side handling pongs for you with no code at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;writeWait&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;
    &lt;span class="n"&gt;pongWait&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;
    &lt;span class="n"&gt;pingPeriod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;25&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt; &lt;span class="c"&gt;// under pongWait, and under the proxy's own idle limit&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;writeLoop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ticker&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewTicker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pingPeriod&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stop&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="k"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;close&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;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;C&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteControl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PingMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;writeWait&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;close&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;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;closed&lt;/span&gt;&lt;span class="o"&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="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;readLoop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetReadDeadline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pongWait&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetPongHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetReadDeadline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pongWait&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="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="n"&gt;clientMsg&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetReadDeadline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pongWait&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&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;Two things worth noticing in that pair. The deadline is refreshed by &lt;em&gt;any&lt;/em&gt; inbound frame, not just pongs, so an active connection is never falsely declared dead. And a missed pong closes the client and frees the room, which is why a crashed publisher does not leave the room stuck as busy for the next viewer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order I would do this in from scratch
&lt;/h2&gt;

&lt;p&gt;Start with the media path, not the app. Before writing a line of signaling code, answer two questions: which UDP ports will carry media, and are they reachable from the open internet. Both answers come from your provider, not from your code, and finding out later means unpicking an assumption you have built on top of.&lt;/p&gt;

&lt;p&gt;Configure the advertised address deliberately, as covered above. Treat "what address does my server claim to be" as a required setting, not something to leave to defaults and hope about.&lt;/p&gt;

&lt;p&gt;Test from a network that is not your development machine. This is not a formality. Your laptop and your server are often on networks close enough that a candidate that cannot work anywhere else still works for you. Open the test page on a phone with wifi off and the cellular radio on, and you are testing something genuinely different: a different carrier, a different NAT, and a different path to your server. If it works there, it works.&lt;/p&gt;

&lt;p&gt;Only then build reconnection. Reconnection logic on top of a media path that does not work is untestable — every failure looks the same and you cannot tell whether you are debugging your retry logic or the network underneath it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The kit
&lt;/h2&gt;

&lt;p&gt;I put what I learned into a starter kit: the signaling server and SFU in Go (Pion v4), the browser publisher and viewer, the auto-reconnect chain, the diagnostics panel, deploy files for systemd, Caddy and Docker, and the full pitfalls doc with symptom, cause and fix for each entry above.&lt;/p&gt;

&lt;p&gt;There is a live demo you can open without an account, and a canvas self-test page that exercises the whole path and needs no camera: &lt;a href="https://lowlatencykit.com" rel="noopener noreferrer"&gt;https://lowlatencykit.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is paid (early access, Basic $99 / Pro $199 / Team $499, with an early-bird code on the landing page). This article is free, and so is the first one — that part is not a teaser, it is the actual content.&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>networking</category>
      <category>devops</category>
      <category>selfhosted</category>
    </item>
  </channel>
</rss>
