Architecture

This page is for people who want to understand — or contribute to — how thinge is built. It covers the priorities that drive the design, the technology choices and why they were made, the components, and how messages flow. The Audio pipeline page goes deeper on the part that is genuinely different.

Priorities

The design follows from one commitment: always-on group voice must feel good — low latency, clear audio, no echo, comfortable to sit in idle. Almost every decision below is downstream of that. Text, presence, and the rest are conventional and were kept deliberately conventional so the effort could go where the product lives.

Two more principles shape the whole system:

  • Signaling is cheap; media is hot. The server does the small, low-rate coordination work; the high-rate audio never passes through it. This separation is the single most important structural idea in the codebase.
  • Self-hosted and standards-shaped. One server you run, standard WebRTC on the wire, no proprietary media protocol — so the trust boundary is your own box and the pieces are inspectable.

Technology choices

Concern Choice Why
Server Elixir + Phoenix Presence, PubSub, and Channels are a natural fit for membership, fan-out, and signaling.
UI Phoenix LiveView One server-rendered UI for browser and desktop alike — no separate frontend app.
Media server ex_webrtc SFU, in-process Standards-compliant WebRTC in Elixir; audio-only forwarding is tractable and keeps v1 to a single deployable.
Desktop shell Tauri 2 Lightweight cross-platform shell with a Rust core.
Client media Native Rust, not the webview OS webviews have inconsistent WebRTC; audio quality needs a real engine.
Client WebRTC str0m (sans-IO) An owned, deterministic, testable event loop — the right base for a hand-built audio pipeline.
Audio codec Opus @ 48 kHz, with DTX The WebRTC voice default; discontinuous transmission makes always-on nearly free on the wire.
Database PostgreSQL Messages, users, channels, subscriptions — the conventional durable state.
NAT traversal coturn (TURN) ~15–20% of networks force a media relay; not optional in the real world.

Components

  • Phoenix server (server/) — REST auth (password, GitHub OAuth, invites), Phoenix Channels for chat and voice signaling, Presence, and the ex_webrtc SFU (one Room process per voice channel). It also serves the entire UI.
  • LiveView UI — server-rendered; the browser and the desktop app load the same pages. Small JavaScript "hooks" wire up the parts the browser must own (audio elements, the voice bridge, screen capture).
  • Desktop client (client/) — a Tauri 2 shell. Its Rust core owns all voice media (capture, encode, the WebRTC engine, decode, mix, playback); the webview owns only the UI and talks to Phoenix directly.
  • Media SFU — a selective forwarding unit: each client keeps one connection to the server, and the server forwards every speaker's audio to every other member of the room. Audio-only means no keyframes and none of the video-SFU machinery.
  • coturn — a TURN relay for clients (or servers) on restrictive networks.

How messages flow

The client is effectively two cooperating processes, and there are two independent paths to the server:

Desktop client
├─ Webview (UI) ──WebSocket: text + presence─────────► Phoenix
│ ▲ (channels,
│ │ Tauri IPC presence,
│ ▼ signaling)
└─ Rust core ──WebSocket: SDP / ICE signaling─────────┘
(str0m, audio) ──UDP: encrypted Opus RTP───────────────► ex_webrtc SFU
(audio forwarding)
  • Control / signaling path (low volume, over WebSockets):
    • UI ↔ Phoenix — text messages, reactions, presence, membership.
    • Rust core ↔ Phoenix — WebRTC signaling only (SDP offer/answer, ICE candidates) to bootstrap the media connection.
  • Media path (high volume, over UDP, never touches Phoenix):
    • Rust core ↔ SFU — DTLS-SRTP-encrypted Opus RTP.

In a browser, there is no Rust core: the page uses the browser's own WebRTC engine for voice, but the same signaling and SFU model applies.

The invariant

Elixir stays out of the RTP hot path. Phoenix only ever brokers the small signaling handshake that sets up the UDP media path. Audio is forwarded by the SFU; it is never decoded, mixed, or transcribed on the server. Text and presence, conversely, never go through Rust.

Holding this line is what keeps the server cheap (it forwards packets, it does not process audio) and the audio good (a real native engine on the client, not a webview).

Deployment

Deployment is deliberately small. The whole product is essentially one Elixir release — Phoenix, the LiveView UI, and the ex_webrtc SFU all run in that single process — plus a PostgreSQL you supply. Everything in deploy/ is just the glue around those two.

  • One app server. In the single-box reference deploy Bandit terminates TLS itself (no reverse proxy — voice UDP wants the raw host); the k8s path instead puts it behind an ingress controller (we use Traefik) that terminates TLS and proxies HTTP and WebSocket upgrades to Phoenix. Either way that WebSocket carries everything low-rate: text, presence, and voice signaling.
  • Postgres is external. thinge connects to a database you run (DATABASE_URL); the manifests don't ship one. A managed instance or an in-cluster operator both work.
  • coturn is optional and separate. The upstream coturn daemon runs as its own service, enabled only if your network needs a media relay. Phoenix mints its short-lived credentials but never relays through it.
  • Media bypasses the ingress. The high-rate paths — the SFU's Opus/RTP and any coturn relay — are UDP straight to the node (the SFU's port range; coturn on 3478 / 5349). They never pass through Traefik. The proxy sits on the control path only, never the media hot path — the same "Elixir out of the RTP hot path" invariant, expressed in the network topology.

So the minimal running system is: one Elixir server (terminating TLS itself, or behind an ingress), one Postgres it connects to, and optionally coturn — with voice media flowing directly over UDP, around any proxy. See Self-hosting for the operational detail.

Alternatives considered

Group voice has three standard topologies. thinge uses an SFU; the choice is worth recording, because "why not just connect everyone directly?" is a fair question.

Topology What the server does Per client Trade-off
MCU decodes everyone, mixes, sends one stream 1 up / 1 down easy on clients, but the server decodes + re-encodes all audio (CPU-heavy) and adds a mixing latency hop
SFU (thinge) forwards each stream, no decoding 1 up / N−1 down cheap server (it just moves packets); the client decodes and mixes
Mesh / P2P nothing — media is peer-to-peer N−1 up / N−1 down no server in the media path, but upload and connection count grow with room size

Because our client already decodes and mixes each speaker on the receive side, that work is the same under an SFU or a mesh — what differs is the send side and the number of connections.

Why not mesh (P2P). Removing the server from the media path sounds appealing, but for an always-on, variable-size, self-hosted product it is the wrong trade:

  • Upload fan-out. Each peer must send its audio to every other peer, so upload bandwidth grows with the room (an SFU uploads once, regardless of size). This is the classic reason mesh does not scale past a handful.
  • N² connections. A room of N is N(N−1)/2 peer connections, each needing its own NAT traversal — and its own relay when a pair cannot connect directly. The SFU is one connection per client.
  • Idle cost. These channels are meant to be sat in by many, mostly-idle people; a mesh keeps everyone maintaining a live connection to everyone else, all day.
  • And since thinge is self-hosted, there is already a server — so "no server in the media path" is not even a saving.

Why not MCU. Mixing on the server is easy on clients but makes the server decode and re-encode everyone — exactly the CPU-heavy work the SFU sidesteps by staying a dumb forwarder. That forwarder simplicity is what lets a small box host real rooms.

The one place P2P wins. For 1:1 and very small calls, a direct peer connection matches the SFU's single-connection cost but with lower latency and zero server bandwidth — and, unlike the SFU (which terminates encryption and can therefore see media), it is the topology that makes end-to-end encryption possible. thinge has no 1:1 voice calls today, but if it grows them — or wants E2EE for sensitive conversations — the natural design is a hybrid: peer-to-peer for the smallest calls, handing off to the SFU as a room grows. (E2EE is a non-goal today for exactly the reason above: the SFU can see media.)

The other bet: our own media engine, not libwebrtc

Topology decides who forwards audio; a separate decision is what WebRTC engine the desktop client runs. thinge builds its own media pipeline on str0m — a sans-IO Rust library that implements the WebRTC protocols (ICE, DTLS, SRTP, RTP) but no media engine — rather than embedding libwebrtc, the mature C++ engine that Chrome ships and most native real-time apps use. This is a genuine bet, not an obvious win, so here is the honest ledger.

libwebrtc would hand us, correct and for free, the exact components we now build or borrow: NetEq (the receive-side jitter buffer, loss concealment, and clock-drift playout), the full audio-processing module, Google Congestion Control, FEC, and NACK — a decade of tuning against real networks. Reimplementing that is not something you would choose lightly.

We chose str0m anyway, mainly for:

  • Build and maintenance. libwebrtc is welded to Chromium's build system — hard to build standalone, cross-compile (we target Linux and macOS), and pin, and it moves quickly. For a small, self-hostable project that is a permanent tax; str0m is a normal Cargo dependency.
  • Testability and control. Sans-IO means we own the clock and the event loop, so the pipeline runs deterministically against seeded network-impairment traces in CI — something a threaded C++ engine does not give you.
  • We already borrow the hardest DSP. Echo cancellation is not hand-rolled: Linux uses libwebrtc's own AEC3, macOS uses Apple's VoiceProcessingIO. So the maturity gap versus libwebrtc narrows mostly to NetEq-class timing and congestion control — not the whole engine.

Where libwebrtc would clearly be ahead: the receive/timing edge and congestion control on bad networks. The open items on the audio roadmap there are the price of this choice — we are re-deriving behavior NetEq already nails. On a LAN or decent Wi-Fi with echo cancellation on (the current use case) it does not matter; if the bar becomes "solid on a genuinely bad mobile network," this is exactly where it starts to cost.

The caveat that weakens our own rationale: when this was decided, using libwebrtc from Rust meant fragile, hand-rolled bindings, which made the build-burden argument nearly absolute. That is less true now — LiveKit maintains production Rust bindings to libwebrtc — so re-deciding today the call would be closer. str0m stays because the pipeline exists, works, and the ownership/testability benefits are real, not because libwebrtc is a bad choice. And it is not one-way: the pipeline already links libwebrtc's audio-processing module behind a seam, so adopting more of libwebrtc (its congestion control, or NetEq-equivalent playout) is open if the timing edge does not close on its own.

Where things live

  • server/ — the Phoenix app: auth, chat/presence channels, the SFU, and the LiveView UI.
  • client/ — the Tauri desktop shell and its Rust workspace, including the native media engine and a hand-rolled TURN client.
  • deploy/ — the container image and deployment manifests, including coturn.