<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[TinyFloor Engineering]]></title><description><![CDATA[How we build TinyFloor, an open source virtual office for remote teams: real-time systems on Cloudflare Durable Objects, WebRTC calls and meetings, and the prod]]></description><link>https://tinyfloor.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ae23487a591929e6e18734c/e5c66fc1-b4b6-4faf-85ac-77d1db66b7e3.png</url><title>TinyFloor Engineering</title><link>https://tinyfloor.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 30 Oct 2026 09:10:30 GMT</lastBuildDate><atom:link href="https://tinyfloor.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[STUN vs TURN: Why Every WebRTC Call in TinyFloor Goes Through a TURN Server]]></title><description><![CDATA[STUN vs TURN explained with a real app: why TinyFloor's WebRTC calls are relay-only, run on Cloudflare's managed TURN, and prefer UDP.]]></description><link>https://tinyfloor.hashnode.dev/stun-vs-turn-webrtc-turn-server</link><guid isPermaLink="true">https://tinyfloor.hashnode.dev/stun-vs-turn-webrtc-turn-server</guid><category><![CDATA[WebRTC]]></category><category><![CDATA[cloudflare]]></category><category><![CDATA[networking]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Jitto Joseph]]></dc:creator><pubDate>Wed, 28 Oct 2026 18:35:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ae23487a591929e6e18734c/b7a84d24-33b5-425a-a90e-7962325a08df.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><a href="https://www.tinyfloor.com">TinyFloor</a> 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.</p>
<p>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.</p>
<h2>STUN vs TURN in one minute</h2>
<p>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.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ae23487a591929e6e18734c/be1b5040-b2d5-4ea4-b719-40e9326415b5.png" alt="The three kinds of ICE candidate: host, srflx (STUN) and relay (TURN)" style="display:block;margin:0 auto" />

<ul>
<li><strong>A STUN server</strong> tells your browser what its public address looks like from outside. That gives a <em>server reflexive</em> (<code>srflx</code>) 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.</li>
<li><strong>A TURN server</strong> is a relay. Your browser gets an address on the TURN server, and every packet of the call goes through it. That's the <code>relay</code> candidate. 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.</li>
</ul>
<p>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.</p>
<h2>What happened when we tried peer-to-peer first</h2>
<p>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.</p>
<p>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".</p>
<h2>Relay-only: one line of config</h2>
<p>So TinyFloor calls are relay-only. In the browser that's one setting on the peer connection:</p>
<pre><code class="language-ts">const pc = new RTCPeerConnection({
  iceServers,
  iceTransportPolicy: "relay",
});
</code></pre>
<p>With <code>iceTransportPolicy: "relay"</code> 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.</p>
<p>The cost is that every call is paid for, all of it. We'll get to whether that matters.</p>
<h2>Why we didn't run coturn</h2>
<p>The usual answer to "I need a TURN server" is <a href="https://github.com/coturn/coturn">coturn</a> 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 <a href="https://tinyfloor.hashnode.dev/how-we-built-a-virtual-office-on-cloudflare-durable-objects">one Durable Object per office</a>, so we used Cloudflare's managed TURN, which answers from whichever data centre is closest to each person.</p>
<p>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:</p>
<pre><code class="language-ts">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();
</code></pre>
<p>The response is a ready-made <code>iceServers</code> array for <code>RTCPeerConnection</code>. Three details made this hold up:</p>
<ul>
<li><strong>Credentials last four hours.</strong> Long enough for a working day of calls, short enough that a leaked one goes stale.</li>
<li><strong>Each person gets one set an hour at most.</strong> The response is cached per user in the Workers Cache API, which also rate-limits calls to Cloudflare for free.</li>
<li><strong>The browser refreshes them 15 minutes before they expire,</strong> so a call that starts late in the afternoon never fails on an old password.</li>
</ul>
<h2>UDP first, TCP and TLS only when needed</h2>
<p>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.</p>
<p>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.</p>
<p>So a call now starts with only the relay's UDP addresses:</p>
<pre><code class="language-ts">export function udpRelay(servers: RTCIceServer[]): RTCIceServer[] {
  return servers.flatMap((server) =&gt; {
    const urls = ([] as string[]).concat(server.urls);
    if (!urls.some((url) =&gt; url.startsWith("turn"))) return [server];
    const udp = urls.filter((url) =&gt; url.startsWith("turn:") &amp;&amp; url.includes("transport=udp"));
    return udp.length ? [{ ...server, urls: udp }] : [];
  });
}
</code></pre>
<p>Some networks do block UDP, though, so TCP and TLS still join, later:</p>
<ul>
<li><strong>After 3.5 seconds without a UDP relay address of our own</strong>, this network is blocking UDP.</li>
<li><strong>After 10 seconds with one but still no connection</strong>, the other end may be the one blocked.</li>
</ul>
<p>Either way the peer connection gets the full server list and gathers again with <code>restartIce()</code>. 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.</p>
<p>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.</p>
<h2>Voice sent twice</h2>
<p>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:</p>
<pre><code class="language-ts">const codecs = RTCRtpReceiver.getCapabilities("audio").codecs;
const isRed = (codec) =&gt; codec.mimeType.toLowerCase() === "audio/red";
transceiver.setCodecPreferences([...codecs.filter(isRed), ...codecs.filter((c) =&gt; !isRed(c))]);
</code></pre>
<p>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.</p>
<h2>What a TURN server costs</h2>
<p>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.</p>
<table>
<thead>
<tr>
<th>What's on the call</th>
<th>Each way</th>
<th>Per call-hour (both people)</th>
</tr>
</thead>
<tbody><tr>
<td>Voice (Opus with RED)</td>
<td>~65 kbps</td>
<td>~0.06 GB</td>
</tr>
<tr>
<td>Voice and a small camera (180p)</td>
<td>~190 kbps</td>
<td>~0.17 GB</td>
</tr>
</tbody></table>
<p>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.</p>
<h2>Takeaways</h2>
<ul>
<li><strong>STUN is the hopeful path; TURN is the one that always works.</strong> Measure how often your calls actually end up relayed before designing around peer-to-peer.</li>
<li><strong>If the answer is "nearly always", go relay-only.</strong> <code>iceTransportPolicy: "relay"</code> gives one path, faster setup and no leaked IP addresses.</li>
<li><strong>Use managed TURN unless you have a reason not to,</strong> and hand out short-lived credentials from your own API.</li>
<li><strong>Prefer UDP and add TCP and TLS late.</strong> A voice call on TCP sounds broken in a way nobody can describe.</li>
<li><strong>Turn on RED for voice.</strong> It's one codec preference and it removes most of the gaps.</li>
</ul>
<h2>Try it</h2>
<p>You can walk up to someone and call them in TinyFloor's <a href="https://www.tinyfloor.com/lobby">demo office</a>, no sign-up needed. TinyFloor is free for teams of up to 3, and <a href="https://github.com/JittoJoseph/TinyFloor">the code is open source on GitHub</a>, including the call code above. If you want the other half of the story, <a href="https://tinyfloor.hashnode.dev/how-we-built-a-virtual-office-on-cloudflare-durable-objects">how each office runs on one Durable Object</a> is in our first post.</p>
]]></content:encoded></item><item><title><![CDATA[How we built a virtual office on Cloudflare Durable Objects]]></title><description><![CDATA[How TinyFloor runs each office as one Cloudflare Durable Object with WebSocket hibernation, and its WebRTC calls through a TURN server and an SFU.]]></description><link>https://tinyfloor.hashnode.dev/how-we-built-a-virtual-office-on-cloudflare-durable-objects</link><guid isPermaLink="true">https://tinyfloor.hashnode.dev/how-we-built-a-virtual-office-on-cloudflare-durable-objects</guid><category><![CDATA[cloudflare]]></category><category><![CDATA[Cloudflare Workers]]></category><category><![CDATA[WebRTC]]></category><category><![CDATA[websockets]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[Jitto Joseph]]></dc:creator><pubDate>Wed, 28 Oct 2026 18:20:13 GMT</pubDate><content:encoded><![CDATA[<p>TinyFloor is a virtual office for remote teams: a pixel-art floor in the browser where everyone has a desk and a status. You can see who's around, walk over to a teammate's desk and call them in one click, and meet in the meeting room down the hall. It's open source, and this post is about how the live part of it works.</p>
<h2>One Cloudflare Durable Object per office</h2>
<p>Every office is one Cloudflare Durable Object. Everyone on the floor holds a WebSocket to it, and it's the one place that knows where people are standing, what their status is, who sits at which desk and what's on the whiteboard.</p>
<p>A Durable Object fits this shape well. An office is small, from a handful of people to fifteen, everything in it has to happen in one order, and it needs a little state that survives restarts. There's no database round trip on the hot path: a move comes in, the room checks it and sends it to everyone else.</p>
<h2>WebSocket hibernation: a room that sleeps between messages</h2>
<p>The room is written so it can sleep between messages:</p>
<ul>
<li><strong>No timers.</strong> Every message is handled and broadcast on the spot. Nothing runs on an interval inside the room.</li>
<li><strong>The heartbeat doesn't wake it.</strong> Clients ping regularly, and the runtime answers with <code>setWebSocketAutoResponse</code> without waking the object.</li>
<li><strong>State lives where hibernation can't lose it.</strong> What each connection needs (who it is, where they stand, their rate limits) is kept in the WebSocket attachment. What the office needs (desks, the whiteboard, usage) is in the Durable Object's own SQLite.</li>
</ul>
<p>A floor of people sitting at their desks costs almost nothing, because a room that isn't receiving messages isn't running. A message wakes it for about a millisecond.</p>
<p>One detail that mattered: reading an attachment back from a socket costs a deserialize, and a broadcast reads every socket's. So the room keeps the current attachments in memory and only writes one back when something that has to survive hibernation changes.</p>
<h2>Movement the server can check</h2>
<p>Characters move on a tile grid. The client moves your character straight away and sends the move; the room checks that it's inside the map and within a rate limit of 12 moves a second. A move that fails gets a <code>move_rejected</code> with where you actually are, and the client snaps back. Everyone else only ever sees accepted moves.</p>
<h2>Calls: WebRTC through a TURN server, every time</h2>
<p>A call between two people is plain WebRTC. The room passes the handshake between the two browsers, and the API hands out short-lived credentials for Cloudflare's TURN server.</p>
<p>We started out letting browsers try a direct peer-to-peer connection first. In testing it almost never worked: firewalls, NATs and mobile networks meant the relay carried nearly every call anyway. So calls are relay-only (<code>iceTransportPolicy: "relay"</code>). One path, faster setup, and the same behaviour on every network.</p>
<p>Two more things made calls sound right:</p>
<ul>
<li><strong>UDP first.</strong> Offered every relay address, Chrome often settled on TCP, where one late packet holds up all the ones behind it. We measured voice arriving up to a second late. Calls now offer only the relay's UDP addresses at first, and add TCP and TLS only if nothing connects after a few seconds.</li>
<li><strong>Voice sent twice.</strong> With redundant audio (RED), each voice packet carries the one before it, so a lost packet is still heard. In our tests the audible gaps went from 8 or 9 per half minute to 0 or 1, for about 30 kbps more.</li>
</ul>
<h2>Meetings: a WebRTC SFU on Cloudflare Realtime</h2>
<p>Three or more people meet in the meeting room, which goes through the SFU in Cloudflare Realtime (formerly Cloudflare Calls) instead of a mesh: everyone sends their audio and video once, and the SFU forwards it. The room opens the SFU sessions on the server, so browsers never hold the app's credentials.</p>
<p>Each person only receives what's on their screen: the speaker in the big card, a few small ones beside it, and a shared screen. So the cost of a meeting grows with the number of people, not with people squared.</p>
<p>The stage switches to a new speaker only after the previous one has paused for 1.2 seconds and has been up for at least 2.5. Any faster and it flickers on every "mm-hm". Who's talking comes from each person's own microphone, sent as a short on/off message rather than a stream of audio levels.</p>
<h2>Audio first</h2>
<p>Video is the expensive part of a call, roughly five times the bytes of voice, and for a quick question voice is what people want anyway. So a call in TinyFloor starts with voice, the camera is a button, and it's off again at the next call. Echo cancellation and noise suppression run in the browser; no audio is ever processed on a server.</p>
<p>That default is a big part of why TinyFloor can charge one flat price for the whole office instead of a price per seat.</p>
<h2>The rest of the stack</h2>
<ul>
<li><strong>Next.js</strong> for the app and the website, with <strong>Phaser</strong> drawing the floor</li>
<li><strong>Cloudflare Workers</strong> for the API, with <strong>Cloudflare D1</strong> for accounts and offices</li>
<li><strong>Durable Objects</strong> for each office's live floor, chat and whiteboard</li>
<li><strong>Cloudflare Realtime</strong> (TURN and the SFU) for calls and meetings</li>
<li><strong>TypeScript</strong> throughout, with the messages between the browser and the room in one shared package</li>
</ul>
<h2>Try it, or read the code</h2>
<p>You can walk around the demo office at <a href="https://www.tinyfloor.com">tinyfloor.com</a> without signing up, and the code is on <a href="https://github.com/JittoJoseph/TinyFloor">GitHub</a> under the AGPL-3.0. If you run a remote team, it's free for up to 3 people.</p>
]]></content:encoded></item></channel></rss>