← All posts

WebRTC without a signaling server

WebRTC is supposed to be peer-to-peer. In practice, it is not. Almost every WebRTC call today depends on servers that someone has to run. The libraries that support WebRTC carry that centralized assumption into their design. This post is about pulling WebRTC back toward peer-to-peer. It is also about the tooling that Holochain apps, and the rest of the peer-to-peer world, need to get there.

Two libraries came out of the work on Presence, our video-call tool for Moss, and we extracted both to that end. This post is about the first, @lightningrodlabs/webrtc-peer. You give it two callbacks: one that sends a message to a peer, and one that you call when a message arrives. It gives you back a managed WebRTC connection for each peer, over whatever peer-to-peer channel your app already has. The next post is about the second library, and about what we do when WebRTC cannot connect at all.

The two servers in the middle

WebRTC is peer-to-peer after it connects. Two servers stand in the way of connecting. The first is the signaling server. Before any media flows, two browsers must exchange an offer, an answer, and a stream of ICE candidates, which are network addresses to try. WebRTC says nothing about how those setup messages travel. So every tutorial starts with a server to carry them, usually a WebSocket relay, and every deployment keeps that server forever.

The second is the relay. Two peers behind strict routers sometimes cannot hole-punch, that is, open a direct path to each other. Then the only way through is a TURN server, which relays the media, and every serious deployment runs one. Traffic through it is not peer-to-peer at all. This post is about removing the first server. The relay is a harder problem, and it is the subject of the next post.

In a Holochain app, the signaling server is not needed. Agents can already send messages directly to each other, through what Holochain calls remote signals. The network layer under Holochain handles NAT traversal and relaying, because everything else needs them too. NAT traversal is how two peers behind home routers reach each other. Presence did its WebRTC signaling this way from the start. There is no server, and the offer goes to the other agent the same way a chat message does.

That is the good news, and it is real. The less good news is what remains after the signaling problem goes away: a raw RTCPeerConnection, which gives you primitives, not a connection.

You must handle both sides negotiating at the same time, because that corrupts the signaling state if you let it happen. You must decide when a dropped ICE path deserves a quick ICE restart and when it needs a full teardown. You must also decide how long to wait between tries. You must collapse four separate state machines (ICE, DTLS, signaling, and the data channel) into one answer that your UI can show. When a connection fails on a laptop in another country, you need a structured record of what happened, not console logs.

Two years with simple-peer

Presence started in December 2023, and matthme built it on simple-peer, the library most people reach for. It served us for two years, and I do not want to be unkind to it. But like most WebRTC libraries, it assumes what a signaling server gives you. That means a reliable, ordered channel, and one side told in advance that it starts the call. Over a peer-to-peer channel, neither holds. By early 2026 we maintained a fork, and the field logs kept describing the same kind of failure, each time in a different form.

The failure that finally moved us was a reconnection loop that we captured in March. A peer on a VPN connected fine the first time. Then something ordinary happened, such as a video toggle or an ICE timeout, and the connection dropped. After that, every reconnection attempt failed. ICE checked for fifteen seconds, went to disconnected, and retried a quarter of a second later, forever, until the user left the room.

Leaving and waiting was the only cure, and the timing told the story. If the user came back after ten seconds, it failed again. If they came back after fifty seconds, it worked. Our retries hammered a NAT that wanted to be left alone, and the harder we retried, the longer it sulked. Retrying is not recovering.

At the end of March, I rewrote the connection layer as a proper state machine. It uses the W3C "perfect negotiation" pattern for glare, the case where both sides send an offer at once. With it, two simultaneous offers resolve instead of corrupting the state, and nobody has to be told in advance who starts. A serialized signaling queue makes sure that SDP operations, the offer and answer steps, cannot interleave.

There is one lifecycle, from idle through signaling, connecting, connected, reconnecting, disconnected, failed, and closed. Its transitions are guarded, so the machine logs an illegal move instead of silently taking it. A two-tier reconnection policy tries a cheap ICE restart first, and a full reconnect only after that, with backoff. A transition log captures a snapshot of every transport state on every move, because the thing we missed most was evidence.

For a few months, the old and new connection layers ran side by side in Presence, and the app chose one per peer. That let us compare them in real rooms. We deleted simple-peer from the codebase at the end of July.

Pulling it out

The connection code knew nothing about Holochain, apart from a small adapter that turned "send this to that agent" into a remote signal. So on May 20 it became a package, for any peer-to-peer app that has its own way to move messages. It asks three things of your transport:

  • A stable, authenticated identity per peer. The library assigns polite and impolite negotiation roles by comparing peer ids, and it trusts you about who is talking.
  • Agent-to-agent delivery, not broadcast.
  • Best-effort delivery. The library tolerates loss, reordering, and duplicates. A session id scoped to the connection filters out signals left over from a previous attempt.

You do not need a reliable ordered channel, a relay, or a rendezvous service. Anything that can carry an opaque JSON blob from one peer to another will do: Holochain remote signals, a libp2p stream, or a plain WebSocket.

It is layered, so you take only the tier that you need. The core is a thin perfect-negotiation wrapper around RTCPeerConnection. On top of that sits the single-peer state machine with the reconnection policy. On top of that sits a ConnectionManager. It owns every peer in a room, routes inbound signals to the right one, propagates your local media, and exposes a view model per peer. The view model has the phase, whether the path is relayed, whether tracks are flowing, and a retry countdown.

It has no runtime dependencies. The browser's RTCPeerConnection is injectable, so the whole library runs headless under a mock in a few seconds. Presence also runs it nightly against real connections in a browser harness. The harness establishes connections, silently drops them, and hands them over. A mock can only fail in the ways that you taught it to.

What it does not do

Discovery, identity, authentication, and transport are the job of your peer-to-peer substrate, and in our case that is Holochain. It cannot conjure a path where there is none. If two peers cannot hole-punch and nobody runs a TURN server, the state machine will tell you precisely that it failed. That is all it can do, and it is the relay problem from the start of this post. The bytes for the offer got through, after all, and that is where the second library starts.

Large calls without a server

Today the library does mesh only. Every peer connects to every other peer and sends its media to each one. That works for a small room. But the number of connections, and the upload bandwidth each peer needs, grow with the square of the number of peers. A mesh call with video maxes out at a handful of people.

The usual answer is an SFU, a selective forwarding unit. An SFU receives each peer's media once and forwards it to everyone else. It is another server in the middle, and it is the reason most video-call products are not peer-to-peer even when a call is small.

Our plan is a distributed SFU. Instead of a server, peers with spare bandwidth volunteer to forward media for the room. A volunteer receives each source once and forwards a chosen subset to the peers that subscribe to it. Every node in the room is still a participant, so the call stays peer-to-peer. A room can grow past the mesh limit with full video.

The type definitions already reserve markers for the forwarding roles. The roadmap lists the three pieces that the work needs: media direction per connection, track forwarding across connections, and a topology with loop prevention. None of it is working code yet, and the roadmap says which pieces belong in the library and which belong to the app.

The even nerdier bits

There are some lessons from this work worth reporting here, so even if no human reads them, an LLM might benefit. A note on process first. We added tooling to Presence so that one participant in a room can request the connection logs from every other participant. We drove all development off those aggregate logs, so we were solving the real-world peer-to-peer issues, not the ones a mock produces. Two of the lessons are specific to peer-to-peer, because there is no server to referee.

Both sides need a shared idea of which attempt is current. A signaling server can order messages and tell both sides which session is live. Over best-effort peer-to-peer delivery, nobody does that for you. In June, we chased a reconnect deadlock of about twenty seconds. Each side's connection minted its own random id. After a teardown, a fresh state machine on one side sometimes latched onto the dead session's id and rejected the live peer's answers as stale.

The fix was an epoch, a counter for the current attempt, on every signal. The orchestrator allocates the epoch, it survives recreation, and it is authoritative for ordering. In August, the same theme came back at a finer grain. We scoped the epoch to the remote side's connection id, with tombstones, records of dead sessions, so a resurrected dead session cannot sneak back in.

Back off, and stop shouting. With no server in front of the NAT, nothing throttles you but yourself. In the August field session that produced 0.5.0, a dead relay drove eleven back-to-back retry sessions in under two and a half minutes. Each one re-flooded some thirty-five ICE candidates at the peer. The retry gap is now exponential, from a few hundred milliseconds out to seven seconds, and the library filters and deduplicates outbound candidates.

The same session showed that giving up from the disconnected state was an illegal transition in our own table. So the machine logged it as blocked and sat there dead instead of failing cleanly. The lifecycle guard did its job and told us. We had just never read that particular line before.

The other lessons are general WebRTC. Connected must mean that media is flowing. The data channel can be stuck while audio and video flow perfectly. So since 0.3.0 the phase means media-ready, and data-channel readiness is its own flag. Emit state changes, not log entries. An app that re-runs its on-connect work on every "connected to connected" log entry will renegotiate a call that was fine.

And one lesson is about us, not WebRTC. After 0.5.0, we found that Presence shipped two releases that still bundled 0.4.0. npm pulled the older version from the registry instead of linking the workspace copy. There is now a test that fails if the app resolves the library anywhere but the workspace package. Fixes that do not ship are not fixes.

← All posts