A video call is more than connecting two media streams
Why an old tab’s hang-up must not end the call that replaced it: numbered seats, idempotent joins, a per-person claim and whole-description signalling in a browser WebRTC demo with no TURN relay.

The desktop in Macalicious, my browser rebuild of macOS Catalina, has a window called FaceTime, and it has nothing to do with Apple’s service. It makes audio and video calls between two accounts on the desktop, and like the rest of the project I developed it by directing coding agents. Sound and picture go peer to peer between the two browsers over WebRTC, a public STUN server helps each side find its address, and the Rust application on Ferrite never sees a frame. What the server handles is signalling: who calls whom, who answers, and the two session descriptions the browsers have to exchange before they can connect.
That sounds like the small half of the job, and by size it is not. The Rust module that carries no media runs to about 70 KB with its tests, nearly twice the React code that drives the peer connection, and a good deal of it exists because a person is not a browser tab.
Suppose you are in a call and you open the desktop in a second tab. FaceTime in that tab sees the call and takes nothing up by itself, offering Join Here and End beside the words “In a call in another window”. You press Join Here, the new tab connects, and you close the old one. Closing a tab fires pagehide, and the old tab’s handler does what it should: it sends a hang-up, in a keepalive request that outlives the page, for the call it believes it holds. It may well still believe that, since word of the move reaches it as a change event or at its next read of the call, which can be up to four seconds away. An ordinary hang-up can be late too, because it is retried with backoff when the network misbehaves. Either way the server receives a hang-up from the right account for the right call after the call has moved, and if the account were all that mattered, the old tab would end the call that replaced it.
So each page in a call names itself with a random token, and joining takes a seat: a number that only grows, tied to the page that asked. A resend of the same page’s join keeps its seat, so a network retry changes nothing, while any other page, whether a reload or a second tab, takes the next number, which replaces the old one. Every hang-up and every signal must name the sender’s seat and counts only while the sender’s account still holds that seat. When the closing tab’s hang-up arrives naming seat 2 and the account now holds seat 3, the server leaves the call as it is. Both tabs carry the same cookie, and the older one has less authority. Anyone who has built a distributed lock will recognise this as a fencing token.
The numbers hide a trap, because the server hands out seats at the moment of joining. If the old tab’s join is delivered again after the new tab has taken over, it looks like a join from another page and earns a higher number than the tab actually in use, taking the call back. So a join also carries an idempotency key, the same one through all of a page’s retries, and each member’s record keeps digests of their last eight. A join delivered again is recognised and changes nothing.
A tab that crashes sends no hang-up at all, so a seat also has to stay alive. A page in a call reads the call every four seconds, naming its seat, and that read is its sign of life. A seat unread for 25 seconds has gone, and the call ends.
Seats fence pages within one call, but they can’t stop a person holding two calls, because neither call’s record knows about the other. Two windows could place or answer different calls in the same instant, and each would find nothing in its way. That takes a claim outside any call: a key in the person’s own space, written with compare-and-set before the call is written, so that of two requests at once only one gets it and the other is refused with “You are already in a call.” A claim holds by itself for ten seconds while its request is still writing. A request slower than that can find its claim taken over, in which case it gives up the seat it just took, which ends that call. The tests run rounds of two windows placing and two windows answering at the same moment, and require one winner and one refusal every time.
The page has to treat its own requests with the same suspicion. One operation runs at a time, placing or answering, and a reply that never came counts as an unknown outcome and not a failure. Placing sends one idempotency key through all its retries, so a lost reply can’t ring a second call, and afterwards the window reads the person’s calls again several times over the next sixteen seconds, taking up a call it finds it holds if the person still wants one and leaving it if not. The sharpest case is a reply that arrives after the window has closed. A closed window makes no more reads, so nothing would ever notice that it had just been given a seat in a call the person abandoned, and the handler for that reply leaves the call on the spot.
Following the seats to the media
Connecting the media is then a matter of following those numbers. The later seat always offers and the earlier one answers, and the server refuses the reverse, so two pages can’t offer to each other at once. Taking a new seat drops the old offer and answer, the new tab makes an offer, and the other person’s browser, seeing that the pair of seats has changed, closes its peer connection and answers the new one. Descriptions go whole, once their ICE candidates have been gathered or three seconds have passed, with no trickling, which leaves one stored record holding the state of the negotiation. A counter kept by the offering page stops a late copy of an older offer from replacing a newer one, and an answer has to name the offer it answers, so one that names a replaced offer is refused and the page negotiates again.
Those descriptions carry network addresses, which is one reason the server is careful about who reads a call. Calling needs a signed-in account, a call’s ID is 128 random bits given only to its two members, every read checks membership, and no shared feed serves a call. News of a change reaches the other person as a counted change on their own personal stream, which tells their pages to read the call again. A member’s view leaves out the other person’s account and page token, and it includes the offer and answer only while they involve that member’s current seat.
Seats can do nothing about the network itself. There is no TURN relay, so two browsers that could reach each other only through one, both behind strict NAT for example, don’t connect, and the window says so: “Your two networks may need a relay server, which this desktop does not have.” The server can’t work that out for them. Every signal can be accepted and both members can be joined while no media flows, because only the browsers know whether their connection succeeded.
That separation shaped the test as well. The journey for FaceTime runs two people in separate browser contexts. The engine’s fake camera looks the same in both, so it paints one person’s camera red and the other’s blue, then samples a pixel from the remote video and requires the other person’s colour and a playback time that keeps moving, because a label saying who is on screen proves nothing. When the blue person moves the call to a second tab, the other person must see blue again, now coming from the new tab. Both contexts run on one machine and connect by host address, so the journey says nothing about anyone’s NAT. What it asks is narrower: whether the seats, the renegotiation and the picture on the screen all agree about who is in the call.
Chris Chabot, November 2026