Freesbe

Blog

We built our own call server, and our apps don't know it exists

By Freesbe4 min read

Every Freesbe huddle and meeting goes through a call server. Each person sends their microphone, camera and screen once, and the server forwards them to everyone else. That kind of server is called an SFU, a selective forwarding unit, and it’s how almost every group video product works.

We started on a managed media service, and it was the right call: we shipped calls in days. But when we looked at our costs, video forwarding was 80 to 90 percent of what Freesbe costs to run per customer. Managed services bill per gigabyte that leaves their network, and video is a lot of gigabytes.

So we wrote our own. The interesting decision wasn’t whether to build one. It was what it should look like from the outside.

The decision: speak the same API

Our apps don’t talk to the call server directly. Web, desktop and mobile all send their call requests through our own backend, which checks that you’re in the call and then passes the request on. When we looked closely, the apps only ever make five kinds of request: open a session, push or pull tracks, answer a renegotiation, close tracks, and get relay servers for networks that block direct connections.

So our server answers those exact requests, at the same paths, with the same shapes. It’s written in Go on top of Pion, a solid open-source WebRTC library, plus a built-in relay (TURN) server for strict networks.

The result: moving a call from the managed service to our server is a change of one base URL in our backend. No app update, no new client library, no feature flag in three codebases.

What we turned down

  • An open-source media server such as LiveKit, mediasoup or Janus. All three are good, and LiveKit in particular is excellent. But each comes with its own client SDK and its own idea of rooms and permissions. Adopting one meant rewriting the call code in the web app, the desktop app and the phone app, and keeping that rewrite in sync while the old path still ran.
  • Staying fully managed. Simple, but it ties the biggest line on our bill to a per-gigabyte price we don’t control.
  • Peer-to-peer for everything. Great for one-to-one calls, but it falls apart past three or four people, because everyone has to upload their video once for each other person.

How a call finds a server

When the first person joins a call, our backend picks a server:

  • one of ours in the workspace’s data region (EU or US), if one is healthy;
  • otherwise the managed service.

Health is a plain HTTP check, cached for 15 seconds. Everyone who joins later goes to the same server as the call’s latest session, so a call never spans two servers. Each session id starts with the name of the server that made it (eu1_…), so every later request for that session goes straight back to the right place without a lookup.

If our server goes down mid-call, the apps already know how to reconnect, because they had to handle flaky networks anyway. They rejoin, the backend sees our server is unhealthy, and the call carries on on the managed service. Nobody had to write failover code in the apps.

The numbers

One modest bare-metal server with a 1 Gbit unmetered port costs about €60 a month. We estimate it carries about 100 four-person video calls at once, or about 1,000 audio calls. Against per-gigabyte pricing it starts paying for itself at around 9 TB of call traffic a month.

That’s not a huge number, which is the point. The managed service stays for regions where we don’t have servers, and as the safety net when ours are down. We only run our own where the traffic justifies it.

Things we learned building it

  • One codec per kind makes life simpler. We use Opus for audio and VP8 for video, which every browser and our phone app send. With one codec, someone can ask for a track before its first packet has even arrived, which happens all the time when two people join at the same moment.
  • Ask for a keyframe when someone starts watching. Otherwise a new viewer sees nothing until the sender’s next keyframe, which can take seconds.
  • Test with real connections. Our test suite opens real WebRTC connections over loopback: push, pull, close, keyframes, and relay-only TURN. Mocked WebRTC tests pass when real ones don’t.

What we’d do differently

Nothing about the API decision. Copying an interface you already depend on is the cheapest exit plan there is, and it’s a pattern that works far beyond video: if you might ever replace a vendor, put a proxy in front of it early and make sure your replacement can answer the same calls.

Our own servers are being rolled out now, starting in Europe. If you’re on a Freesbe call, you won’t be able to tell which one you’re on, and that’s exactly the goal.

Try all of it free with up to 10 people.

No card, no time limit. Invite your team and see what sticks.