In 2023, I was working on an app that had an Apple Watch companion. The Watch needed to talk to the phone. Simple, right?
Apple gives you WatchConnectivity for this. It's their official framework. It's also garbage.
Our connection success rate was 60%. Four out of ten attempts, the Watch and phone would just... not talk to each other. Messages vanished. isReachable returned true while nothing was getting through. I'm convinced isReachable is just a random bool generator with a confidence problem. The Watch app would sit there spinning, the user would restart it, and maybe it'd work. Maybe.
The team had thrown multiple engineers at this problem before. Retry logic, timeout tweaks, radars filed (lol). Every approach followed Apple's documentation to the letter. None of it worked because the framework itself was the problem, and everyone kept trying to fix it from the inside.
When I pitched a completely different approach, the reaction was basically: "Okay, prove it."
Fair enough.
The Pitch Nobody Believed
The idea was to bypass WatchConnectivity entirely and treat this as a plain networking problem. BLE for service discovery, HTTP for data, Server-Sent Events for push. No Apple frameworks in the hot path.
The general sentiment was "multiple people have already tried to fix this, what makes you think this will work?"
Because they were trying to fix WatchConnectivity. I wasn't going to use it. Sometimes the best way to fix something is to pretend it doesn't exist.
Why This Works
The architecture is simple enough to draw on a napkin:
- The phone runs a lightweight HTTP server on the local network
- The phone advertises its IP over BLE using a custom GATT characteristic
- The Watch discovers the phone via BLE, reads the IP, and connects over HTTP
- Phone → Watch push happens over SSE (Server-Sent Events)
- Watch → Phone is regular HTTP POST
- WatchConnectivity participates as a full transport alongside HTTP, both racing to deliver
Every message gets a frame ID, an ack, and retry logic. Deduplication prevents double-delivery. A ping/pong heartbeat detects connection loss. If a transport goes down, unacked messages retry when it comes back.
None of this is revolutionary. Framing, acknowledgment, retransmission. TCP 101. The kind of stuff I learned as a teenager reverse engineering game server protocols. The only novel part is that nobody thought to apply it here.
For the record, the first version of this ran Vapor as the HTTP server on the iPhone. Yes, a full Swift web framework embedded in a mobile app. The Watch was long polling for new messages. It worked. Honestly I'm still proud of it. Shipping a full Vapor instance inside an iPhone is unhinged in the best way. We even had to contribute a fix to Vapor itself because it was spinning up 64 threads by default. On a phone. Because why would a web framework assume it's running on anything other than a server? The current version with bare Network.framework and SSE is just what happens when you have years to strip something down to its essentials.
I should mention: I didn't ship this alone. After the initial proof of concept, a colleague joined me and we built and shipped this thing together. The "we" you've been reading isn't a royal we. This was also 2023, before LLMs were writing code for people. Two engineers, a hex editor mentality, and a lot of stubbornness.
60% → 99%
That was the result. Not over weeks of tuning. Pretty much immediately.
The connection success rate went from 60% to 99%. The remaining 1% is edge cases like the Watch being out of Bluetooth and Wi-Fi range simultaneously. Can't help you there. Take it up with physics.
The Part Nobody Talks About
This architecture doesn't care what operating system the phone runs.
WatchConnectivity is iOS-only. But BLE? HTTP? SSE? Those work everywhere.
An Android phone can advertise its IP over BLE using the same service UUID, and the Watch picks it up the same way it would from an iPhone. From there it's just HTTP. Same protocol, same framing, same reliability. The Watch doesn't know or care what's on the other end.
And I don't mean this theoretically. In production, we had a proprietary device (not a phone) that couldn't do BLE advertising, so it posted its IP address to a database. The phone fetched it and relayed it to the Watch. The Watch connected to this third-party device over HTTP and it just worked. If your device has an IP address and you can get that IP to the Watch somehow, WatchLink will talk to it. Your toaster probably qualifies at this point.
The v1 open-source release is Swift only. I'm not going to write Kotlin for the sake of shipping a v1, but upcoming versions will expand to Android, macOS, and eventually Node.js so your Watch can talk to a web app running on a computer. That said, if you look at what the host side actually does, it's trivial to reimplement. Run an HTTP server, advertise your IP over BLE with the right service UUID, speak the same frame protocol. That's it.
As of today, April 2026, there is no other public solution for Android-to-Apple-Watch communication. Nothing. Not a library, not a blog post, not a Stack Overflow answer. Eleven years after the Apple Watch launched, this is it.
Don't take my word for it. Here's a Reddit thread where someone asked about Android + Apple Watch communication. The top responses: "this is basically blocked by Apple", "if it were possible then someone would have definitely done it by now", and my personal favorite, "Send/receive WHAT data?" The consensus was that it's impossible. It's not. It's just networking.
To be clear about scope: WatchLink fixes sendMessageData, the real-time messaging path. Apple's background APIs (transferUserInfo, updateApplicationContext) are a separate thing that WatchLink doesn't touch. Those are queued, best-effort, arrive-whenever systems. They also barely work, but that's a rant for another day.
sendMessageData already requires both apps to be active and reachable. That's Apple's own rule. WatchLink didn't add this constraint. It just made the API actually deliver on its promise.
The difference is what happens when things go wrong. WC has retry and queuing logic, but it's inconsistent. Sometimes messages arrive. Sometimes they vanish. Sometimes the error handler fires repeatedly for messages that already succeeded. The accepted answer on that thread? "Give identifiers to all your messages and track which ones you've seen." People were reinventing frame IDs by hand just to survive WC.
WatchLink does this properly. Every message has a unique ID, gets queued, and stays queued until the receiving device itself sends an ack. Not when the transport claims it was delivered. The device. Watch goes to sleep? Messages wait. Wrist comes back up, BLE rediscovers, HTTP reconnects, queue flushes. Ack gets lost and the same message arrives twice? Dedup catches it. Exactly-once delivery. The stuff networking figured out in the 70s.
For cross-platform, there's no WatchConnectivity at all, so both sides need to be active. But real-time Watch communication is inherently an active session thing: workouts, live health data, device control. If the user is looking at both devices, WatchLink is delivering.
WatchLink
I've cleaned it up and open-sourced it as WatchLink.
It's a Swift Package with three modules:
- WatchLinkCore: the protocol layer. Framing, ack/retry, dedup, state machine. Zero platform dependencies.
- WatchLink: the Watch-side client. BLE discovery, HTTP transport, SSE listener, connection management.
- WatchLinkHost: the phone-side host. HTTP server (built on Network.framework, no dependencies), BLE advertiser, SSE push.
Swift 6, no dependencies beyond what Apple ships. The code is on GitHub, go read it.
Usage
Define your messages:
struct HeartRate: WatchLinkMessage { let bpm: Int }
Watch side:
let link = WatchLink { config in config.transports = [.watchConnectivity, .http] config.bleServiceUUID = yourServiceUUID config.bleIPCharacteristicUUID = yourIPCharUUID config.httpPort = 8188 } await link.connect() try await link.send(HeartRate(bpm: 72))
Phone side:
let host = WatchLinkHost { config in config.transports = [.watchConnectivity, .http] config.bleServiceUUID = yourServiceUUID config.bleIPCharacteristicUUID = yourIPCharUUID config.httpPort = 8188 } try await host.start() for await hr in await host.messages(HeartRate.self) { print("Heart rate: \(hr.value.bpm)") }
The API is unified. send() for fire-and-forget, send() with a Response type for request-response, reply() for responding to incoming requests. One interface for both Watch and Host, the type system does the routing.
Request-response is built in too. Same send(), the type system figures out the rest:
// Define a message that expects a response struct TimeRequest: WatchLinkMessage { typealias Response = TimeResponse } // Watch asks, phone answers let response = try await link.send(TimeRequest())
There's a full PingPong example app in the repo if you want to see everything wired together.
The Backstory
I grew up in Egypt. When I was 13 or 14, I was reverse engineering MMORPG private servers. Packet structures, TCP framing, the whole thing. No formal training, just hex editors and stubbornness. My parents thought I was playing games. Technically correct.
That's where I actually learned how networking works. Not from school or any textbook, but from a teenager's conviction that he could make a game server bend to his will. The game server did not always agree, but I learned a lot from the arguments.
A decade later, Apple's official Watch communication framework was failing 40% of the time and I'm sitting there like "I've literally solved harder problems than this when I was 14." And I had. The protocol I built for WatchLink is simpler than what I was dealing with back then. Framing, acks, retries. Stuff I could do in my sleep because a game server taught me the hard way what happens when you don't.
Funny how that works.
What's Next
WatchLink is production-ready. It ran in production at scale for years before this open-source release. If you're building a Watch app and fighting WatchConnectivity, or if you're crazy enough to want Android ↔ Apple Watch communication, this is your solution.
It's the only one that exists. Literally. I checked. Repeatedly. I kept thinking someone must have done this already. They didn't.