The iPad's WebRTC data channel opened fine, but messages bigger than ~8KB never arrived—because two bugs in separate layers were wearing a trenchcoat. p2claw's app loaded a blank page on iPad Safari while the same URL worked on Mac, Linux, and phone, all on the same Wi-Fi.
No console errors, service worker registered, handshake completed, data channel state said "open." The browser sent a GET over the channel and waited forever. The box agent on the other end had served the response and pushed bytes onto the channel—they just never made it to the iPad. Refresh like crazy and it sometimes worked. Classic heisenbug.
The Heisenbug: Blank Page, No Console Errors
First useful move: log both ends of the connection and line them up by clock time. p2claw's instrumentation tracked every chunk the box sent, every chunk the browser received, and how much data sat in the sender's outbound buffer waiting for delivery confirmation. Per request, the box sent three chunks: a 220-byte header, a 7,874-byte body, and a 199-byte tail. The sender's outbound buffer climbed to about 8KB and stopped dead—holding the body it had "sent" but never got ACKed.
On the iPad, the browser's JS console showed exactly one chunk received (the header) and then nothing. No body, no small headers of following requests. WebRTC data channels guarantee in-order delivery on lossy UDP, so one missing chunk blocks everything after it. Safari on the Mac received 8KB and 11KB chunks without a hiccup.
What the Numbers Actually Said
Candidate pair stats from WebRTC's getStats() told the real story: the iPad froze at 2,144 bytes received across 18 packets, while the data channel had delivered exactly one message (266 bytes, the header plus framing). The box was retransmitting the big packet the whole time. If WebKit were dropping packets, we'd see different behavior—but the pattern pointed squarely at a fragmentation issue.
The breakthrough: the iPad had Tailscale enabled. Tailscale wraps traffic in an extra layer, reducing the room in each packet by ~60 bytes for headers. Big WebRTC messages got sliced into more, smaller IP fragments. WebKit implements data channels in userspace, including reassembly—and that's where the first bug lives. a hardcoded constant in webrtc-rs (the Rust WebRTC stack used by the box agent) capped something too tightly when Tailscale's overhead pushed packet sizes past a threshold.
The Trenchcoat: webrtc-rs and Tailscale
Cap the box's messages at 800 bytes—small enough to fit in a single packet—and the iPad loaded instantly, Tailscale on or off. That felt like case closed. But a first attempt at 1,200 bytes (which Claude helpfully calculated should fit) mysteriously didn't work. The team spent two weeks building a standalone reproduction with a JavaScript sender, then a Rust sender. Nothing reproduced. Until they re-read their own evidence using Anthropic's Fable to dig up JSONL logs from the original debugging session.
Two bugs wearing a trenchcoat: a hardcoded buffer constant in webrtc-rs that assumed no IP-over-IP overhead, and a one-line design decision in Tailscale that silently increased packet size without signaling the change. p2claw patched a workaround the same day, but understanding what they'd actually patched took two more weeks.
Next time you see a heisenbug that only bites on iOS with a VPN, check your MTU—and instrument both ends of the data channel before you chase ghosts.
Source: The iPad was on Tailscale: a WebRTC debugging story
Domain: p2claw.com
Comments load interactively on the live page.