<?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>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>
    <item>
      <title>What actually breaks when you build a WebRTC SFU in Go</title>
      <dc:creator>AiLaoHuYu</dc:creator>
      <pubDate>Sat, 19 Sep 2026 06:23:39 +0000</pubDate>
      <link>https://dev.to/ailaohuyu/what-actually-breaks-when-you-build-a-webrtc-sfu-in-go-45af</link>
      <guid>https://dev.to/ailaohuyu/what-actually-breaks-when-you-build-a-webrtc-sfu-in-go-45af</guid>
      <description>&lt;p&gt;Every Pion example I found stops at "hello world": two peer connections, one track, it works, the blog post ends. That is the easy 20%. The other 80% is what happens &lt;em&gt;after&lt;/em&gt; — when the publisher's connection drops, when a viewer joins mid-stream, when a reverse proxy reaps your signaling socket, when the person streaming switches browser tabs.&lt;/p&gt;

&lt;p&gt;I built a low-latency streaming server: one Go binary doing WebSocket signaling plus SFU forwarding, with a browser publisher, viewer and diagnostics panel. These are the nine things that actually cost me time. Symptoms first, because that is what you will be searching for at 2am.&lt;/p&gt;

&lt;p&gt;Spoiler for the whole article: &lt;strong&gt;most of these look like a media problem and are not one.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;TrackRemote.WriteRTCP&lt;/code&gt; is gone in Pion v4
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;remote.WriteRTCP undefined (type *webrtc.TrackRemote has no field or method WriteRTCP)&lt;/code&gt; after upgrading from v3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; v4 moved RTCP &lt;em&gt;writing&lt;/em&gt; onto the &lt;code&gt;PeerConnection&lt;/code&gt;. A track can only read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; keep the publisher's &lt;code&gt;*webrtc.PeerConnection&lt;/code&gt; next to the track and call &lt;code&gt;pubPC.WriteRTCP(pkts)&lt;/code&gt; — and rewrite the target SSRC first, because viewer feedback (PLI / FIR / NACK) references the &lt;em&gt;viewer's&lt;/em&gt; SSRC, not the publisher's:&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="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="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;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="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;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="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="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;Get that SSRC rewrite wrong and you get the worst kind of bug: RTCP flows, nothing errors, and the publisher just never sends you a keyframe.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;event.streams&lt;/code&gt; is empty in &lt;code&gt;ontrack&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; the viewer's peer connection reports &lt;code&gt;inbound-rtp&lt;/code&gt; bytes and a resolution, ICE is &lt;code&gt;connected&lt;/code&gt;, &lt;code&gt;getStats()&lt;/code&gt; looks perfect — and the video element stays black.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; &lt;code&gt;video.srcObject = e.streams[0]&lt;/code&gt; where &lt;code&gt;e.streams&lt;/code&gt; is an empty array assigns &lt;code&gt;undefined&lt;/code&gt;. Nothing throws. It looks like a media failure and it is a UI bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; fall back to a stream built from the track itself:&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ontrack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;streams&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;streams&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MediaStream&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;track&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="nx"&gt;ms&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;This one is nasty precisely because the statistics are &lt;em&gt;good&lt;/em&gt;. Every number says the stream is arriving. Only &lt;code&gt;video.srcObject&lt;/code&gt; knows the truth, and nothing queries it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Stale JavaScript after every edit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; you change &lt;code&gt;viewer.js&lt;/code&gt;, reload, and the old behaviour is still there. Hard reload fixes it. Then it comes back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; &lt;code&gt;http.FileServer&lt;/code&gt; sends &lt;code&gt;Last-Modified&lt;/code&gt; but no &lt;code&gt;Cache-Control&lt;/code&gt;, so the browser applies heuristic caching and serves the script from memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; wrap the static handler and send &lt;code&gt;Cache-Control: no-cache&lt;/code&gt; so the browser always revalidates. Cheap — 304s carry no body.&lt;/p&gt;

&lt;p&gt;The reason this is worth its own entry: it makes you doubt your own fixes. You "verify" a change, see the old behaviour, and go looking for a bug that is not there.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Forwarding RTP without rewriting payload types
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; the viewer's SDP negotiates fine, but Chrome drops every packet or shows a black frame, and the server logs a codec the viewer never agreed to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; the server's default &lt;code&gt;MediaEngine&lt;/code&gt; has its own payload-type table. If the publisher sends VP8 on PT 96 and the viewer side re-offers it as PT 100, the RTP you forward blindly is garbage for the viewer — the payload type is a &lt;em&gt;number&lt;/em&gt;, and the number is per-connection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; build the viewer-side &lt;code&gt;PeerConnection&lt;/code&gt; from a &lt;code&gt;MediaEngine&lt;/code&gt; that registers the publisher's own codec parameters, &lt;em&gt;including its payload type&lt;/em&gt;:&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;me&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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;MediaEngine&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;t&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;tracks&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;me&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RegisterCodec&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;Codec&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;Kind&lt;/span&gt;&lt;span class="p"&gt;())&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;WithMediaEngine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;me&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then track which codecs that PC was built for. If the publisher adds a codec the PC does not know, &lt;strong&gt;rebuild the PC&lt;/strong&gt; rather than trying to renegotiate it in place. Trying to be clever here is how you get an intermittent bug that only shows up when someone publishes screen share after camera.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. ICE candidates that arrive before the remote description
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;AddICECandidate: InvalidStateError&lt;/code&gt;, and connections that only come up on the second try.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; &lt;code&gt;onicecandidate&lt;/code&gt; on the client fires before its SDP has reached the server, so trickled candidates land while the server still has no remote description to attach them to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; buffer candidates per client until &lt;code&gt;SetRemoteDescription&lt;/code&gt; has been applied, then flush them. It is about fifteen lines and it removes an entire class of "sometimes it doesn't connect".&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Republishing stacks state
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; after the publisher's connection fails and it republishes, viewers see &lt;em&gt;two&lt;/em&gt; video tracks — or the picture only updates every other attempt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; the publisher's &lt;strong&gt;websocket survived&lt;/strong&gt;, so the server kept its old &lt;code&gt;PeerConnection&lt;/code&gt;, its old track list and its old viewer senders, and merged the new session into them. From the server's point of view this is not a new publisher at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; if a second offer arrives on a session whose remote description is already set, treat it as a republish. Close the server-side PC, clear the room's tracks, tear down every viewer PC, broadcast &lt;code&gt;publisher{live:false}&lt;/code&gt;, then negotiate from scratch. Viewers must also drop their &lt;code&gt;PeerConnection&lt;/code&gt; on &lt;code&gt;live:false&lt;/code&gt; — otherwise they hold a dead one and autoplay silently stops.&lt;/p&gt;

&lt;p&gt;This is the bug that taught me the difference between "the socket is alive" and "the session is alive". They are not the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. A backgrounded tab stops the source
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; the self-test page reports &lt;code&gt;running…&lt;/code&gt; forever — no frames, no bitrate — but &lt;strong&gt;only&lt;/strong&gt; when you switch to another tab. Coming back "fixes" it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; the canvas source was drawn from &lt;code&gt;requestAnimationFrame&lt;/code&gt;, which browsers stop entirely for hidden documents. No drawing means &lt;code&gt;captureStream()&lt;/code&gt; has no frames to emit, so the publisher sends nothing. It looks like a media failure. It is not one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; drive the source from a timer (&lt;code&gt;setInterval&lt;/code&gt;) instead of &lt;code&gt;rAF&lt;/code&gt;, and decide pass/fail from &lt;code&gt;framesDecoded&lt;/code&gt; in &lt;code&gt;getStats()&lt;/code&gt; rather than from &lt;code&gt;requestVideoFrameCallback&lt;/code&gt; — which is also rendering-dependent. A backgrounded tab paints nothing but still decodes.&lt;/p&gt;

&lt;p&gt;This one is worth internalising beyond the test page: &lt;strong&gt;a publisher's tab is usually backgrounded.&lt;/strong&gt; Anything you build on rAF will stop working the moment your user switches to another window, which is exactly when they are streaming.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Renegotiating once per track
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; viewers receive one offer per track when a publisher starts audio + video, and browsers answer them out of order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; &lt;code&gt;OnTrack&lt;/code&gt; fires once per track. Negotiating inline races.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; debounce. One &lt;code&gt;time.AfterFunc(250ms)&lt;/code&gt; per room, cancelled and restarted on each new track, that renegotiates every viewer once. Serialize viewer-side SDP handling in the browser with a promise chain too, or the same race reappears on the other end.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Reverse proxies drop the idle signaling socket
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; everything works locally, but behind Cloudflare or nginx a long session suddenly reconnects — or a publisher's socket dies mid-stream and comes back as a &lt;em&gt;second&lt;/em&gt; publisher (see #6).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; signaling is a flurry of messages at connect time and then goes completely silent for as long as media flows. Reverse proxies close idle WebSockets on a timer — Cloudflare around 100s, nginx's &lt;code&gt;proxy_read_timeout&lt;/code&gt; 60s by default. Nothing is wrong with the media path. The signaling socket just gets reaped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; let the &lt;strong&gt;server&lt;/strong&gt; generate the traffic, and require a pong. The instinct is a JS &lt;code&gt;setInterval&lt;/code&gt; heartbeat on the client, but browsers cannot send protocol-level ping frames from JS, and timers get throttled in a background tab — which is exactly where a publisher's tab sits:&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;// writeLoop&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="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="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;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="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="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// readLoop&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="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="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="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="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;Protocol pings are answered by the browser itself, so this works even in a hidden tab.&lt;/p&gt;

&lt;p&gt;It also buys you liveness detection for free: a peer that stops ponging is dropped within 60s and releases the room, instead of leaving it stuck on "room busy" after someone closes their laptop. A reconnecting publisher then looks like a &lt;em&gt;second&lt;/em&gt; publisher — which is what #6 defends against. That is not a coincidence; that is the actual sequence of bugs you hit, in order.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern worth taking away
&lt;/h2&gt;

&lt;p&gt;Five of the nine present as "the video is broken". Two of those are not media bugs at all (#2 is a UI assignment, #7 is rAF being paused). The rest are &lt;em&gt;lifecycle&lt;/em&gt; bugs: a socket that outlives its session, a candidate that arrives before the description it belongs to, a payload type that means different things on different connections.&lt;/p&gt;

&lt;p&gt;WebRTC gives you excellent statistics and no error messages. So the instinct is to go straight to &lt;code&gt;getStats()&lt;/code&gt; — and it will happily tell you that everything is fine while the user stares at a black rectangle. &lt;strong&gt;Check the boring layer first.&lt;/strong&gt; Is &lt;code&gt;srcObject&lt;/code&gt; set? Is the tab visible? Did the socket get reaped? Those three questions would have saved me most of the time these nine bugs cost.&lt;/p&gt;

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

&lt;p&gt;I put the result of all this into a starter kit — the signaling server and SFU (Go, Pion v4), the browser publisher and viewer, the auto-reconnect chain, the diagnostics panel, the deploy files for systemd / Caddy / Docker and the no-domain-tunnel route, plus the full pitfalls doc with symptom → cause → 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). The pitfalls above are free, and always will be — that part is not a teaser, it is the actual content.&lt;/p&gt;

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