DEV Community

AiLaoHuYu
AiLaoHuYu

Posted on

Remote video support that survives a technician's phone network

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.

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.

The whole thing in one line: field video fails in four different layers, and they look identical from the UI.

It works in the office and fails on cellular

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

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 srflx candidate is still gathered, still looks valid, and every packet sent to it is dropped, because the mapping was opened for a different destination.

Symptom: on Wi-Fi the connection establishes in under a second; on cellular it never leaves checking.

What to look at: log the selected candidate pair. If it is never relay when the call fails, you need TURN before you debug anything else.

pc.oniceconnectionstatechange = () => {
  if (pc.iceConnectionState === 'failed') {
    // which pair did we actually end up on?
    pc.getStats().then((s) => s.forEach((r) => {
      if (r.type === 'candidate-pair' && r.state === 'failed') {
        console.log('failed pair', r.localCandidateId, r.remoteCandidateId);
      }
    }));
  }
};
Enter fullscreen mode Exit fullscreen mode

The relay port answers but the allocation is refused

"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.

First, a STUN probe against the same host and port as your TURN server. If it returns an srflx candidate, the port is open and something is listening. Then a real allocation with the real credentials. A relay 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.

# coturn: open the control port AND the relay range
listening-port=3478
min-port=49152
max-port=65535

# a cloud VM must be told its own public address
external-ip=203.0.113.10
Enter fullscreen mode Exit fullscreen mode

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.

Video freezes when the technician walks to the van

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.

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.

Do not trust the connection state alone. A connection can read connected while frames have stopped. Watch the frame callbacks instead, and treat a missing frame as frozen even while ICE still says connected:

let last = performance.now();
video.requestVideoFrameCallback(function tick() {
  last = performance.now();
  video.requestVideoFrameCallback(tick);
});
setInterval(() => {
  if (performance.now() - last > 3000) restartChain(); // stalled, not disconnected
}, 1000);
Enter fullscreen mode Exit fullscreen mode

ICE connects but no frame ever decodes

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 172.16.x.x 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.

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

The tab says connected but nothing arrives

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.

The fix is a protocol-level ping from the server, 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.

The check order that saves time

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

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.

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:

https://lowlatencykit.com/field-assist/

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

https://lowlatencykit.com/test/turn/

Top comments (0)