WebRTC on iOS in 2026, without building a video call
Every tutorial assumes you want a video chat. I wanted a transport: five data channels, no HTTP for app data, and the road there is documented nowhere.
I tap a button on my iPhone and a Simulator on my Mac presses it. When the network allows it, no server touches that tap: it crosses my living room as an encrypted packet, phone to Mac. When the network doesn't allow it, a relay forwards the packet without being able to read it. Either way, my server never learns what my thumb is doing.
That's WebRTC. The thesis: WebRTC is a transport that happens to ship with a video kit. It's the only Apple-sanctioned way to get a low-latency, NAT-crossing pipe between two consumer devices, and its data channels can carry your entire app. Mine carry touches, a terminal, build commands, logs and file listings, over five named channels. But the whole ecosystem is shaped like video calls.
01 · PRIOR ARTWhat already exists, honestly
- A WebSocket through your server works everywhere and is a fraction of the code. The costs: every message pays a round trip to a data center, your server sees everything, and you pay the egress bill. If your two devices always share a LAN, a plain WebSocket to a Bonjour-discovered port is simpler than everything below. Stop here.
- MultipeerConnectivity, Apple's own device-to-device framework: nearby devices only, and undebuggable when it silently fails. Cross-network is out.
- Raw sockets: your home router's NAT (the box that shares one public address among your devices) eats unsolicited packets. This is the problem WebRTC exists to solve; hand-rolling it means reimplementing NAT traversal from scratch.
- Tailscale solves reachability beautifully, but asks your users to install a VPN on both devices. Fine for you, not for customers.
- WebRTC for the Curious is the reference text and better than any paragraph I'll write about the protocol itself. This article is about what it doesn't cover: shipping the thing on iOS and macOS.
The thirty seconds of WebRTC you need
Two devices that want to talk directly need three things. An introduction service, called signaling: any channel over which they exchange a description of themselves (an SDP, a text blob of capabilities and addresses). A way to find a working path, called ICE: both sides list every address they might be reachable at (each one a candidate), then try pairs until one works. The algorithm is honest brute force, and Tailscale's NAT traversal article explains the hole-punching underneath better than I will. And a fallback, TURN: a relay that forwards encrypted packets when no direct path exists, without being able to decrypt them.
What you get at the end is the part video tutorials treat as a footnote: the data channel, a message pipe like a WebSocket, except the other end is a device, not a server.
03 · THE SDKThe framework with one header
First problem: there's nothing official to link against. Google stopped publishing prebuilt WebRTC binaries years ago; building from source means depot_tools and a Chromium checkout. The Swift apps I've seen all use stasel/WebRTC, a community XCFramework served over Swift Package Manager. I pin M147; M151 is current as I write.
On iOS it just works. On macOS, the framework's macOS slice ships with exactly one header file. Try it on yours:
ls SourcePackages/artifacts/webrtc/WebRTC/WebRTC.xcframework/macos-x86_64_arm64/WebRTC.framework/Headers | wc -l
The Catalyst slice next to it has the full set. So my Mac app has a build phase that copies the Catalyst headers into the macOS slice, deletes the five that reference UIKit (which doesn't exist on macOS), and rewrites the module map. Fifty-eight lines of shell, mandatory, documented nowhere I could find. If you ship a Mac companion against stasel's package, you'll need it (true as of M147; check your version's slice before assuming).
04 · FIVE CHANNELSFive channels, and why five
My app opens one peer connection with five data channels. They exist because of two properties you choose per channel, and one you can't opt out of.
The two you choose are reliability and ordering:
touch:maxRetransmits = 0, unordered. A finger position that arrives late is worse than one that never arrives, because the next position has already replaced it, so this channel is allowed to drop.terminal: reliable and ordered, the defaults. Terminal bytes are a program: drop one escape code and the screen corrupts.
The one you can't opt out of is head-of-line blocking: inside one ordered channel, message four waits for message three, always. That's why there are three more channels instead of one:
control: commands and request/response ("install this build", "cancel").events: small server-push notices from the Mac.streams: high-volume output, build logs mostly.
Each channel is its own lane (its own SCTP stream, the protocol multiplexing the channels), so a megabyte of build log on streams never delays a cancelBuild on control. The comment in my code says exactly that, because I wrote it after learning it the slow way.
Everything rides a small versioned JSON envelope ({"v":1,"cmd":"cancelBuild"} shaped). And the scope that makes the headline honest: auth, TURN credentials and subscription receipts still use HTTPS to my server. The app's own actions never do. No HTTP for app data is the invariant; when I migrated the last actions onto control in May, the rule was that each action's HTTP path got deleted in the same change. Two transports for one action is how you never finish a migration.
The signaling server you'll write anyway
Signaling needs so little that any WebSocket relay works. Mine is a small room-based server on Fly.io: two roles per room, forward SDPs and candidates, kick the older socket when the same role connects twice. That last feature nearly killed the app.
My first iOS build created a fresh signaling client on every connect() call. UI state changes and network probes call connect() freely, so a second client would open while the first was alive; the server kicked the older socket with "Replaced by new connection"; the kicked client's receive loop scheduled a reconnect; that reconnect kicked the newer socket. The two clients chased each other's tails at multiple reconnects per second, forever. The fix is a rule: one persistent signaling client per room, recreated only when the room itself changes. Repeat connect() calls no-op on the live client.
Reconnection is the actual feature
Networks flap. Phones background. The naive answer, tear everything down and redo the handshake, means several seconds of dead air every time, and users read every one of them as "the app is broken."
WebRTC's own answer is ICE restart: keep the peer connection and its channels alive, renegotiate only the network paths underneath. The catch is that a restart offer (the SDP one side proposes) and a brand-new peer look almost identical on the wire, and my signaling happens to enforce one-socket-per-role by kicking, so "my phone got a new IP" and "my other iPhone just took over the session" both arrive as the same event on the Mac.
I distinguish them with one field: the phone sends a per-install device id with every join. Mac side, when a peer (re)joins:
- Same device id, live connection: it's a network flap. Restart ICE, keep the media sockets, the five channels never close.
- Different device id: it's a takeover. Full renegotiation, and the replaced phone bans itself from auto-reconnecting into that room, because two devices auto-reclaiming kick each other in a loop until both give up. Only an explicit user action (a Retry tap, the app coming to the foreground) takes a room back.
Two more lifecycle rules that each ate a day:
- Backgrounding tears down fully. iOS won't keep a UDP socket healthy in your pocket, so pretending otherwise just moves the failure somewhere weirder. Foreground counts as user intent and reconnects.
- Trust channels, not ICE state. I've watched connections where ICE reports connected and every channel is dead. My recovery watchdogs force-rebuild the connection, and the force-rebuild is coalesced: several watchdogs firing in the same window would otherwise tear down the rebuild the first one started, turning recovery into the thing that prevents recovery.
Candidates: send fewer, connect faster
Reconnection decides whether the pipe survives; what decides how fast it forms is what you put in the candidate list. With trickle ICE (candidates sent as they're discovered instead of all at once), each address you send lands in the peer's list of pairs to try. My Mac sits behind the same NAT rules as any laptop, so I filter what I put on the wire: no TCP candidates, no private-router addresses (the 192.168.x.x family), no link-local (self-assigned, single-network) addresses, no mDNS .local names that only resolve on the LAN. A candidate the other side can't possibly reach isn't a lottery ticket, it's noise in the list that delays the pair that works. LAN connections still work on every network I've tested, through the same machinery: the reflexive candidates (the public address the other side discovered for you) cover it.
TURN needs credentials, and the long-lived secret must never ship in an app binary. My server proxies Cloudflare's ephemeral-credentials API: the app asks my server, my server asks Cloudflare, the app gets a temporary username and password, cached for 18 hours against the 24-hour expiry.
08 · VIDEOThe one video note
There is a video track (the mirrored screen), and the only non-obvious knob: I set the encoder's minimum bitrate to 1 Mbps. Not to force quality: it biases the bandwidth estimator's floor so recovery after a congestion dip is fast instead of a slow crawl up from nothing. Real congestion can still push below it. Maximum bitrate and framerate come from a remote config, so caps change without an app release.
09 · STILL BROKENStill broken
- The relay path occasionally drops packets in ways I haven't root-caused. Direct and hole-punched paths are solid.
- Backgrounding is a full teardown. That's iOS reality, not a bug I can fix, but the reconnect will always cost a beat.
- The one-header macOS slice still needs my script as of M147.
- The header patch has to survive SwiftPM re-fetching the artifact, so the build phase re-checks the header count on every build and re-patches when it's been reset.
Three things I'd tell someone starting
- The data channels are the product. Ignore everything in the API named after cameras and microphones until you need it; a peer connection with five channels and no media is a legitimate, stable configuration.
- The connection lifecycle is the app. One persistent signaling client. ICE restart for flaps, full renegotiation for takeovers, and a device id to tell them apart. Flag flips instead of teardowns wherever possible.
- Migrate clean-cut. Every action I moved off HTTP deleted its HTTP path in the same change. The migration finished because no action ever had two transports.
The tap still crosses the living room, and the server still doesn't know. Next article is the one I already promised: how a subscription follows an account across three devices without the server ever seeing your name.