DEV Community

Cover image for WebRTC works on your laptop. Here is what breaks when you put it on a server.
AiLaoHuYu
AiLaoHuYu

Posted on

WebRTC works on your laptop. Here is what breaks when you put it on a server.

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.

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.

The split that explains most "it connects but there's no video"

Signaling and media take different paths, and only one of them goes through the infrastructure you configured.

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.

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.

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.

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.

Trap 1: your ICE candidates are lies

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.

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.

There are two ways out, and they are not equivalent:

Use a STUN server. 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.

Tell the server its public IP. 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.

In Pion that is a setting engine call, made once before you build the API:

se := webrtc.SettingEngine{}
se.SetNAT1To1IPs([]string{opts.PublicIP}, webrtc.ICECandidateTypeHost)
api := webrtc.NewAPI(webrtc.WithSettingEngine(se))
Enter fullscreen mode Exit fullscreen mode

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.

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.

Trap 2: the firewall that has nothing to do with your app

Cloud providers give you two firewalls and only one of them is the one you think of as a firewall.

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.

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.

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.

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.

Here is how to tell trap 1 and trap 2 apart, because from the outside they look the same:

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.

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.

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.

Trap 3: your reverse proxy reaps the idle socket

This one is a different failure shape and it is worse, because everything works first.

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.

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.

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.

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.

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.

The shape of it in gorilla/websocket, with the browser side handling pongs for you with no code at all:

const (
    writeWait  = 10 * time.Second
    pongWait   = 60 * time.Second
    pingPeriod = 25 * time.Second // under pongWait, and under the proxy's own idle limit
)

func (c *Client) writeLoop() {
    ticker := time.NewTicker(pingPeriod)
    defer ticker.Stop()
    for {
        select {
        case msg := <-c.send:
            if err := c.conn.WriteJSON(msg); err != nil { c.close(); return }
        case <-ticker.C:
            if err := c.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeWait)); err != nil {
                c.close(); return
            }
        case <-c.closed:
            return
        }
    }
}

func (c *Client) readLoop() {
    defer c.close()
    _ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
    c.conn.SetPongHandler(func(string) error {
        return c.conn.SetReadDeadline(time.Now().Add(pongWait))
    })
    for {
        var m clientMsg
        if err := c.conn.ReadJSON(&m); err != nil { return }
        _ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
        c.handle(m)
    }
}
Enter fullscreen mode Exit fullscreen mode

Two things worth noticing in that pair. The deadline is refreshed by any 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.

The order I would do this in from scratch

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.

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.

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.

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.

The kit

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.

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: https://lowlatencykit.com

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.

Top comments (0)