STUN vs TURN: Why Every WebRTC Call in TinyFloor Goes Through a TURN Server
Peer-to-peer almost never connected for us, so our calls are relay-only: UDP first, on Cloudflare's managed TURN, with voice sent twice.

TinyFloor is a virtual office for remote teams: a shared floor in the browser where you walk your character to a teammate's desk and call them in one click. Those calls are WebRTC, one to one, and every one of them goes through a TURN server.
That wasn't the plan. We started the usual way, trying a direct peer-to-peer connection first and falling back to a relay. This post is what we learned: the difference between STUN and TURN, why we went relay-only, why we didn't run our own coturn, and the two fixes that made voice sound right over a relay.
STUN vs TURN in one minute
Two browsers on different networks can't just send each other packets. Each one sits behind a router that rewrites its address (NAT), and often behind a firewall too. WebRTC solves this with ICE: each browser gathers a list of addresses it might be reached at, called candidates, swaps them with the other side, and tries them in pairs until one works.
- A STUN server tells your browser what its public address looks like from outside. That gives a server reflexive (
srflx) candidate. STUN is cheap: one small request, and then the media goes straight between the two browsers. It works through many home routers, and fails behind symmetric NATs, mobile carriers and most office firewalls. - A TURN server is a relay. Your browser gets an address on the TURN server, and every packet of the call goes through it. That's the
relaycandidate. It works on almost any network, because to the firewall it looks like a normal connection to a server, at the cost of carrying every byte of the call.
So the question in "STUN vs TURN" isn't which one to use. STUN is the hopeful path; TURN is the one that always works. The question is how often you end up on the relay anyway.
What happened when we tried peer-to-peer first
For us, nearly always. In testing, direct connections almost never worked: offices behind firewalls, phones on mobile data, home routers with strict NAT. The relay carried nearly every call regardless, and our bills agreed. Before we changed anything, the TURN relay carried three times the traffic of the SFU that runs our group meetings.
Trying direct first wasn't free either. Each call spent time gathering and testing candidate pairs that were going to fail, and the same call behaved differently depending on whose Wi-Fi it was on. Two people in the same café might connect directly; two people in two offices never did. That's the worst kind of bug report: "calls are slow sometimes".
Relay-only: one line of config
So TinyFloor calls are relay-only. In the browser that's one setting on the peer connection:
const pc = new RTCPeerConnection({
iceServers,
iceTransportPolicy: "relay",
});
With iceTransportPolicy: "relay" the browser only gathers relay candidates. Host and STUN candidates are skipped, so there is one path, setup is faster, and a call behaves the same on every network. A side effect worth having: neither person learns the other's IP address, because all either side ever sees is the relay.
The cost is that every call is paid for, all of it. We'll get to whether that matters.
Why we didn't run coturn
The usual answer to "I need a TURN server" is coturn on a VPS. It's solid software, and we didn't want to run it: a server per region to be close to people, TLS certificates, ports to open, bandwidth to watch, and someone to wake up when it falls over. The rest of TinyFloor already runs on Cloudflare, with one Durable Object per office, so we used Cloudflare's managed TURN, which answers from whichever data centre is closest to each person.
You never ship a TURN password to the browser. Our API asks Cloudflare for short-lived credentials and hands them out to signed-in people only:
const CREDENTIAL_TTL_SECONDS = 4 * 60 * 60;
const response = await fetch(
`https://rtc.live.cloudflare.com/v1/turn/keys/${env.TURN_KEY_ID}/credentials/generate-ice-servers`,
{
method: "POST",
headers: { Authorization: `Bearer ${env.TURN_KEY_API_TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ ttl: CREDENTIAL_TTL_SECONDS }),
},
);
const { iceServers } = await response.json();
The response is a ready-made iceServers array for RTCPeerConnection. Three details made this hold up:
- Credentials last four hours. Long enough for a working day of calls, short enough that a leaked one goes stale.
- Each person gets one set an hour at most. The response is cached per user in the Workers Cache API, which also rate-limits calls to Cloudflare for free.
- The browser refreshes them 15 minutes before they expire, so a call that starts late in the afternoon never fails on an old password.
UDP first, TCP and TLS only when needed
A TURN server listens on UDP, TCP and TLS, and the list Cloudflare returns has all three. We handed the whole list to the browser, and Chrome often settled on TCP.
TCP is the wrong transport for voice. It delivers every packet in order, so one late packet holds up all the ones behind it. We measured it on 28 September: voice arriving up to a second late, with a fifth of it patched over by the decoder guessing. People said calls felt "laggy" without being able to say why.
So a call now starts with only the relay's UDP addresses:
export function udpRelay(servers: RTCIceServer[]): RTCIceServer[] {
return servers.flatMap((server) => {
const urls = ([] as string[]).concat(server.urls);
if (!urls.some((url) => url.startsWith("turn"))) return [server];
const udp = urls.filter((url) => url.startsWith("turn:") && url.includes("transport=udp"));
return udp.length ? [{ ...server, urls: udp }] : [];
});
}
Some networks do block UDP, though, so TCP and TLS still join, later:
- After 3.5 seconds without a UDP relay address of our own, this network is blocking UDP.
- After 10 seconds with one but still no connection, the other end may be the one blocked.
Either way the peer connection gets the full server list and gathers again with restartIce(). Only the side that made the offer can restart ICE, so the other side asks it to over the same signalling channel. Each end can land on its own transport: one person on TCP behind a strict office firewall, the other still on UDP at home.
One detail took a while to find. The timers start when both sides have agreed on the call, not when the call is placed. Otherwise the other person reading an "Allow microphone?" prompt looks exactly like a blocked network.
Voice sent twice
Even on UDP, packets get lost. Opus hides small losses well, but on a busy Wi-Fi you still hear gaps. Browsers support RED (redundant audio, RFC 2198): each voice packet also carries the one before it, so a lost packet arrives anyway with the next one. Turning it on is a codec preference:
const codecs = RTCRtpReceiver.getCapabilities("audio").codecs;
const isRed = (codec) => codec.mimeType.toLowerCase() === "audio/red";
transceiver.setCodecPreferences([...codecs.filter(isRed), ...codecs.filter((c) => !isRed(c))]);
In the same tests the audible gaps fell from 8–9 per half minute to 0–1. It roughly doubles the voice bitrate, about 30 kbps more, which next to video is nothing. Browsers that don't support RED just send plain Opus.
What a TURN server costs
Relay-only means paying for every byte, so here are the numbers. Cloudflare charges $0.05 per GB delivered to browsers, after 1 TB free each month, shared between TURN and the SFU. Traffic coming in is free.
| What's on the call | Each way | Per call-hour (both people) |
|---|---|---|
| Voice (Opus with RED) | ~65 kbps | ~0.06 GB |
| Voice and a small camera (180p) | ~190 kbps | ~0.17 GB |
At those rates the free terabyte covers around 17,000 hours of voice calls a month. A 2 h 45 min camera-on call we measured used 624 MB, which also showed us a quality cap being silently skipped (fixed). If you're deciding between running coturn and paying for managed TURN, do this sum first: for most small apps the relay bill is a rounding error, and your own time isn't.
Takeaways
- STUN is the hopeful path; TURN is the one that always works. Measure how often your calls actually end up relayed before designing around peer-to-peer.
- If the answer is "nearly always", go relay-only.
iceTransportPolicy: "relay"gives one path, faster setup and no leaked IP addresses. - Use managed TURN unless you have a reason not to, and hand out short-lived credentials from your own API.
- Prefer UDP and add TCP and TLS late. A voice call on TCP sounds broken in a way nobody can describe.
- Turn on RED for voice. It's one codec preference and it removes most of the gaps.
Try it
You can walk up to someone and call them in TinyFloor's demo office, no sign-up needed. TinyFloor is free for teams of up to 3, and the code is open source on GitHub, including the call code above. If you want the other half of the story, how each office runs on one Durable Object is in our first post.
Comments
Join the discussionNo comments yet. Be the first to comment.
