The iPad was on Tailscale: a WebRTC debugging story

syllogistic1 pts0 comments

The iPad was on Tailscale · p2claw

← all posts<br>The iPad was on Tailscale

2026-06-10

If you're not familiar with how p2claw works, it's worth checking out the how it works blog post before diving into this one.

I opened one of my p2claw apps on my iPad and got a blank page. The same URL was working on my Mac, my linux box and my phone. On the same wifi, same browser engine, same network.

Like in a good detective story, we came up with a bunch of suspects [the iPad, then WebKit, then Tailscale] and they all turned out to be innocent. Sort of. It turned out to be two bugs wearing a trenchcoat: a hardcoded constant in webrtc-rs, and a one-line design decision in Tailscale that we found through sheer stubbornness. We had a workaround patched the same day, but understanding what we had actually patched took two more weeks.

The complaint

The app loaded enough HTML to paint the loading state and then hung. There were no relevant console errors, the Service Worker registered, the WebRTC handshake finished, the data channel opened [dc.readyState === "open"], and then nothing. The browser sent its first GET / over the data channel and waited forever for the response.

The box agent on the other end thought everything was fine. It had served the response and pushed the bytes onto the channel. They just never made it to the iPad.

If that wasn't tricky enough, it was a heisenbug: if I refreshed like crazy, the page would sometimes load.

When in doubt, instrument

The first useful thing we did was log both ends of the connection and line the logs up by clock time: every chunk the box sent, every chunk the browser received, and, crucially, how much data the box was holding in its outbound buffer waiting to be confirmed delivered. That helped us figure out where the data was not making it to the other end.

Dead ends

After discarding everything up to and including the webrtc handshake, we were grasping at straws. We checked some webrtc specific limits and double checked network stability.

A message-size limit. In WebRTC, before two peers start exchanging data they agree on the largest single chunk each will accept. If you send something bigger, some browsers just silently hang up. We read that limit (maxMessageSize) off both devices. The iPad reported 64kb, exactly the same as the Mac, and far above the 7-8kb chunks we were sending. After this, we felt like we had discarded message chunk size as a culprit, which ended up making the true diagnosis harder to arrive at.

Flaky wifi. The cheapest explanation: packets getting lost over the air. ifstat and tcpdump were clean on the box, and my phone [on the same wifi] did not exhibit the same problem.

It had to be something specific to the iPad, but we had no idea what.

What the numbers actually said

Per request, the box sent three chunks: a 220 byte header, a 7,874 byte body, and a 199 byte tail. Our new instrumentation showed the sender's outbound buffer climb to about 8kb and stop. It was holding the body it had "sent" but could never get confirmation it had arrived. When the ipad refreshed, we saw the same identical pattern.

WebRTC data channels guarantee in-order delivery on top of lossy UDP, so one missing chunk blocks subsequent messages. On the iPad, in the browser's js console, we saw exactly one chunk being received [the 220 byte header] and then nothing. We didn't see the body or the small headers of the following requests.

We tested on Safari on the Mac, guessing the issue might be WebKit since it happened on every ios browser [and all ios browsers are webkit under the hood], but the Mac was receiving 8kb and 11kb chunks without a hiccup.

"It was Tailscale"

After two hours of WebKit theories, I realized that, unlike the Mac, the iPad had Tailscale enabled.

Tailscale is a VPN, and a VPN wraps your traffic in an extra layer that leaves less room in each packet. So the big responses got sliced into more, smaller pieces on the way to the iPad than they did to the Mac. WebKit implements data channels itself, in userspace, including reassembling big messages from the packets that carry them. Our theory evolved toward a bug in webkit message reassembly.

We capped the box's messages at 800 bytes, small enough that each one rode a single packet, and the iPad loaded instantly, Tailscale on or off. It felt like case closed [actually a first attempt at 1,200 bytes, which Claude helped me calculate should fit, mysteriously didn't work. Hold that thought].

In hindsight, we had just discovered that the issue was the VPN, and yet we stuck to our WebKit theory. Given our context bloat [both mine and the agents', this is troubleshooting in the age of AI after all], the Tailscale discovery got absorbed into the WebKit theory instead of challenging it. We could have looked at the network and the webrtc sender, but instead we took it as one more reason the browser was at fault. So we wrote the incident up as an iOS Safari bug [the device gets the packets but never...

ipad tailscale webrtc webkit data browser

Related Articles