When I added Apple Watch support to Soccer Game Manager, I assumed the hard part would be the UI — cramming a useful score-tracking interface onto a 44mm screen. That turned out to be the easy part. The hard part was getting the iPhone and Watch to reliably talk to each other.
WatchConnectivity is one of those frameworks that looks straightforward in the WWDC session and reveals its complexity in production. The lifecycle is nuanced, the transfer methods have different guarantees, and the fact that either device can be unreachable at any given moment means you have to think carefully about what "sending data" actually means in your app.
Here's what I learned building it for real.
How WatchConnectivity Works
The WatchConnectivity framework provides a single shared session — WCSession — that manages the communication channel between your iOS app and its paired watchOS extension. There's one session on each side, and they communicate through a small set of transfer mechanisms, each with different delivery guarantees.
Before you can use any of them, you need the session activated on both sides.
Setting Up WCSession
The iOS side
The recommended pattern is a dedicated connectivity manager, implemented as a singleton so the session stays alive for the duration of the app's lifecycle:
import WatchConnectivity
@MainActor
final class WatchConnectivityManager: NSObject, WCSessionDelegate, ObservableObject {
static let shared = WatchConnectivityManager()
private override init() {
super.init()
guard WCSession.isSupported() else { return }
WCSession.default.delegate = self
WCSession.default.activate()
}
// MARK: - WCSessionDelegate
nonisolated func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
if let error {
print("WCSession activation failed: \(error)")
}
}
nonisolated func sessionDidBecomeInactive(_ session: WCSession) { }
nonisolated func sessionDidDeactivate(_ session: WCSession) {
// Reactivate after deactivation (e.g., user switches Apple Watch)
WCSession.default.activate()
}
}
A few things worth noting:
WCSession.isSupported() — always check this before accessing WCSession.default. It returns false on iPads, which don't support Watch pairing. Skipping this check is an easy crash.
sessionDidDeactivate — this fires when the user switches to a different Apple Watch. You must call activate() again here to reestablish the session with the new device. Forgetting this means your app silently stops communicating after a Watch swap.
Activate early — call activate() as early as possible, ideally in your app's initialization path. Session activation is asynchronous and can take a moment. If you wait until the user tries to send something, you'll have a race condition.
The watchOS side
The watchOS setup is nearly identical, but without the sessionDidBecomeInactive and sessionDidDeactivate methods — those only exist on the iOS side:
import WatchConnectivity
@MainActor
final class WatchConnectivityManager: NSObject, WCSessionDelegate, ObservableObject {
static let shared = WatchConnectivityManager()
private override init() {
super.init()
guard WCSession.isSupported() else { return }
WCSession.default.delegate = self
WCSession.default.activate()
}
nonisolated func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
if let error {
print("WCSession activation failed: \(error)")
}
}
}
Checking Reachability Before Sending
This is where a lot of developers get tripped up. Just because the session is activated doesn't mean the counterpart device is reachable. WCSession exposes several properties you need to check before attempting certain transfers:
var canSendToWatch: Bool {
WCSession.default.isPaired &&
WCSession.default.isWatchAppInstalled &&
WCSession.default.isReachable
}
isPaired— is an Apple Watch paired to this iPhone?isWatchAppInstalled— is your watch app installed on that Watch?isReachable— is the Watch currently in Bluetooth range and awake?
isReachable is the one that bites you. It's false when the Watch is out of range, the screen is off, or the watch app isn't in the foreground (on watchOS 2+, background app execution is limited). You can't depend on it for reliable delivery.
This is the fundamental design constraint that shapes everything else: for real-time communication, both devices need to be awake and in range. For reliable delivery, you need a transfer mechanism that queues and delivers when conditions allow.
The Four Transfer Methods
WCSession provides four ways to send data, each suited to different use cases.
1. sendMessage — Real-time, foreground only
// Send
WCSession.default.sendMessage(
["score": ["home": 2, "away": 1]],
replyHandler: { reply in
print("Watch acknowledged: \(reply)")
},
errorHandler: { error in
print("Message failed: \(error)")
}
)
// Receive (on the other side)
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
Task { @MainActor in
// Update UI
}
}
nonisolated func session(
_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void
) {
// Handle message and reply
replyHandler(["status": "received"])
}
sendMessage is the fastest transfer mechanism — it's as close to real-time as WatchConnectivity gets. But it requires isReachable to be true, and it will fail immediately if the counterpart isn't reachable. Use it for live updates where immediate delivery matters and you can handle failure gracefully.
For Soccer Game Manager, this is how I sync score changes while a game is actively in progress and both devices are in use.
2. updateApplicationContext — Latest state, background delivery
// Send
try? WCSession.default.updateApplicationContext(
["gameState": encodedGameState]
)
// Receive
nonisolated func session(
_ session: WCSession,
didReceiveApplicationContext applicationContext: [String: Any]
) {
Task { @MainActor in
// Update from context
}
}
Application context is designed for "latest state" synchronization. It queues the data and delivers it when the counterpart device is next reachable — even in the background. Critically, only the most recent context is delivered. If you call updateApplicationContext five times before the Watch wakes up, only the last call reaches it.
This is ideal for state that supersedes itself — current score, current game status, current settings. It's not suitable for event streams where every update matters.
It also doesn't require isReachable — which means it works reliably even when the Watch is on the charger or out of range.
3. transferUserInfo — Queued delivery, every item guaranteed
// Send
WCSession.default.transferUserInfo(
["event": "goal", "team": "home", "minute": 34]
)
// Receive
func session(
_ session: WCSession,
didReceiveUserInfo userInfo: [String: Any]
) {
// Handle each delivered item
}
Unlike application context, transferUserInfo queues every transfer and guarantees delivery — each dictionary is delivered in order, even if the app or Watch is backgrounded or temporarily out of range. The tradeoff is latency: delivery is opportunistic, not immediate.
Use this for event logs, match history entries, or anything where losing an individual update would be a problem. I use it in Soccer Game Manager to log goal events so the game history stays consistent across both devices even if the Watch goes out of range mid-match.
You can inspect the pending queue:
let pendingTransfers = WCSession.default.outstandingUserInfoTransfers
And cancel individual transfers if needed:
pendingTransfers.first?.cancel()
4. transferFile — Large data, background delivery
// Send
let fileURL = // URL to a local file
WCSession.default.transferFile(fileURL, metadata: ["type": "seasonExport"])
// Receive
func session(
_ session: WCSession,
didReceiveFile file: WCSessionFile
) {
// file.fileURL points to a temporary location — copy it immediately
let destination = // your permanent storage location
try? FileManager.default.copyItem(at: file.fileURL, to: destination)
}
transferFile is for larger payloads — exported season data, cached images, anything too large for a dictionary. Like transferUserInfo, delivery is guaranteed and happens in the background.
The critical detail in the receive handler: copy the file immediately. The file.fileURL points to a temporary location that the system will clean up. If you hold a reference instead of copying, you'll find it gone the next time you try to use it.
Handling Background Transfers Correctly
Background transfers (updateApplicationContext, transferUserInfo, transferFile) are delivered on a background thread. Since WCSessionDelegate methods are called off the main actor, you need to hop back to the main actor before touching any UI or @Published properties.
The modern way to do this is with @MainActor on the class and nonisolated on the delegate methods, combined with Task { @MainActor in } to dispatch work back to the main actor:
nonisolated func session(
_ session: WCSession,
didReceiveApplicationContext applicationContext: [String: Any]
) {
Task { @MainActor in
self.updateUI(from: applicationContext)
}
}
Marking the manager class @MainActor ensures your @Published properties and UI-touching methods are always accessed on the main actor. The nonisolated keyword on each delegate method tells the compiler these callbacks arrive from outside the actor — the Task { @MainActor in } block then safely bridges back in.
This replaces the old DispatchQueue.main.async pattern and integrates cleanly with Swift's structured concurrency model.
A Practical Decision Tree
Given all four mechanisms, here's how to choose:
| Scenario | Method |
|---|---|
| Live score update, both devices active | sendMessage with updateApplicationContext fallback |
| Sync current game state on Watch wake | updateApplicationContext |
| Log an event that must not be lost | transferUserInfo |
| Export season data to Watch | transferFile |
The pattern I use for live updates is to attempt sendMessage first, and fall back to updateApplicationContext in the error handler:
func sendScoreUpdate(_ score: Score) {
let payload = score.toDictionary()
if WCSession.default.isReachable {
WCSession.default.sendMessage(payload, replyHandler: nil) { [weak self] error in
// Fell back to context on failure
try? WCSession.default.updateApplicationContext(payload)
}
} else {
try? WCSession.default.updateApplicationContext(payload)
}
}
This gives you the best of both worlds — real-time delivery when possible, reliable background delivery when not.
What I Got Wrong the First Time
A few mistakes I made that might save you time:
Activating the session too late. I initially activated WCSession in response to the first user action that needed it. This created a window where the session wasn't ready and transfers silently failed. Move activation to app startup.
Assuming isReachable means the Watch is ready. Even when isReachable is true, sendMessage can fail if the watch app isn't in the foreground. Always implement the error handler and have a fallback.
Not copying received files. Lost a few test exports before I learned this one the hard way.
Updating UI off the main actor. Background delivery delegate methods are called off the main actor. Mark your manager @MainActor and use Task { @MainActor in } inside nonisolated delegate methods to bridge back safely.
Using sendMessage for everything. It feels like the obvious choice because it's the most familiar (it looks like a network request), but it's the most fragile. For state synchronization, updateApplicationContext is almost always the better tool.
The Mental Model That Helped
The most useful reframe for me was this: don't think of WatchConnectivity as a network connection. Think of it as a set of mailboxes with different delivery guarantees.
sendMessage is a phone call — immediate, but only works if the other person picks up. updateApplicationContext is a whiteboard — you can update it anytime, but the other person only sees the latest version when they look. transferUserInfo is certified mail — slower, but every letter arrives. transferFile is a courier for large packages.
Once you think about it that way, choosing the right mechanism for each use case becomes a lot more intuitive.