Watch Connectivity
Watch Connectivity
Section titled “Watch Connectivity”Core principle
Section titled “Core principle”Watch Connectivity is an opportunistic optimization, never the primary data path. Independent watchOS apps can be installed without their iOS companion at all (Family Setup, or a user who simply never installed the phone app), so any feature that only works via WCSession breaks for those users. Design the app to fetch from the network or CloudKit first; layer Watch Connectivity on top for when a paired, reachable iPhone happens to be available.
Session activation
Section titled “Session activation”One WCSession singleton per process, activated once at launch on both sides:
import WatchConnectivity
final class ConnectivityProvider: NSObject, WCSessionDelegate { static let shared = ConnectivityProvider()
override init() { super.init() guard WCSession.isSupported() else { return } WCSession.default.delegate = self WCSession.default.activate() }
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { /* handle */ }
#if os(iOS) func sessionDidBecomeInactive(_ session: WCSession) { } func sessionDidDeactivate(_ session: WCSession) { WCSession.default.activate() // reactivate for the next paired watch } #endif}Assign the delegate before calling activate() — otherwise the activation callback can arrive before anything is listening. On iOS, implementing both sessionDidBecomeInactive(_:) and sessionDidDeactivate(_:) is required to support more than one paired watch.
Choosing a transfer method
Section titled “Choosing a transfer method”| Method | Queued | Overwrites prior | Wakes receiver | Best for |
|---|---|---|---|---|
updateApplicationContext(_:) |
No | Yes — replaces old context | Next launch | Snapshots where only the latest value matters (current song, settings, last sync time) |
transferUserInfo(_:) |
Yes | No — FIFO | Next launch (background) | Events that all matter in order (messages, appointments, score updates) |
transferCurrentComplicationUserInfo(_:) |
Yes | No — FIFO | Immediately (50/day limit) | Complication refresh triggers — the only method that wakes the watch specifically for complications |
transferFile(_:metadata:) |
Yes | No — FIFO | On receipt (background) | File payloads (images, audio, large JSON) |
sendMessage(_:replyHandler:errorHandler:) |
No | — | Only if both apps are reachable/active | Live request-response while both apps are running |
updateApplicationContext — latest-wins state
Section titled “updateApplicationContext — latest-wins state”try WCSession.default.updateApplicationContext([ "lastSync": Date().timeIntervalSince1970, "trackTitle": currentTrack.title,])Received via session(_:didReceiveApplicationContext:). If three updates queue while the receiver sleeps, only the newest survives.
transferUserInfo — ordered queue
Section titled “transferUserInfo — ordered queue”let transfer = WCSession.default.transferUserInfo([ "event": "new-message", "id": messageID, "text": messageText,])Each call produces a WCSessionUserInfoTransfer. Inspect session.outstandingUserInfoTransfers to see what’s in flight, and cancel a stale one with transfer.cancel() rather than letting it deliver.
transferCurrentComplicationUserInfo — budgeted
Section titled “transferCurrentComplicationUserInfo — budgeted”if WCSession.default.isComplicationEnabled { WCSession.default.transferCurrentComplicationUserInfo(payload)}let remaining = WCSession.default.remainingComplicationUserInfoTransfers50 transfers per day per complication. watchOS 27 fixes a longstanding bug (FB12819178) where this method didn’t work with WidgetKit-based complications at all on earlier releases. The receiver persists the payload (typically to shared UserDefaults via an App Group) and reloads the widget:
WidgetCenter.shared.getCurrentConfigurations { result in if case .success(let list) = result { for info in list { WidgetCenter.shared.reloadTimelines(ofKind: info.kind) } }}transferFile — background file transfer
Section titled “transferFile — background file transfer”let transfer = WCSession.default.transferFile(fileURL, metadata: ["kind": "image"])// transfer.progress for UIDelete the source file inside session(_:didFinish:error:) once the transfer completes — it stays on disk otherwise.
sendMessage — live only
Section titled “sendMessage — live only”WCSession.default.sendMessage( ["request": "nowPlaying"], replyHandler: { reply in /* runs on a background thread */ }, errorHandler: { error in /* not reachable, or timed out */ })Requires isReachable == true on both sides, and the reply handler must return quickly or the system times it out. From the watch, sendMessage wakes a reachable companion iPhone app.
Always complete every background task
Section titled “Always complete every background task”This is the single most common Watch Connectivity crash pattern. watchOS wakes the app with a WKWatchConnectivityRefreshBackgroundTask to deliver queued transfers. Every task must reach setTaskCompletedWithSnapshot(_:) — skipping it drains the background-time budget, and the app gets SIGKILLed once it’s exhausted, often long after the actual bug.
Retain tasks and complete them once activation settles and no content is pending:
private var wcBackgroundTasks: [WKWatchConnectivityRefreshBackgroundTask] = []
func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) { for task in backgroundTasks { if let wcTask = task as? WKWatchConnectivityRefreshBackgroundTask { wcBackgroundTasks.append(wcTask) } else { task.setTaskCompletedWithSnapshot(false) } } completeBackgroundTasks()}
private var activationObs: NSKeyValueObservation?private var pendingObs: NSKeyValueObservation?
func bootstrap() { activationObs = WCSession.default.observe(\.activationState) { _, _ in DispatchQueue.main.async { self.completeBackgroundTasks() } } pendingObs = WCSession.default.observe(\.hasContentPending) { _, _ in DispatchQueue.main.async { self.completeBackgroundTasks() } }}
private func completeBackgroundTasks() { guard WCSession.default.activationState == .activated, !WCSession.default.hasContentPending else { return } wcBackgroundTasks.forEach { $0.setTaskCompletedWithSnapshot(false) } wcBackgroundTasks.removeAll()}Reachability and companion state
Section titled “Reachability and companion state”let s = WCSession.default
s.activationState // .notActivated / .inactive / .activateds.isPaired // iOS only — is any watch paireds.isWatchAppInstalled // iOS only — does the paired watch have the companions.isComplicationEnabled // is a complication on an active watch faces.isReachable // both apps active and reachable right nowGuard every send against the right precondition: transferUserInfo works offline, sendMessage fails immediately if isReachable == false, and isComplicationEnabled should gate every spend of the 50/day complication budget.
isReachable is a hint, not a delivery guarantee — it can read true while a sendMessage/sendMessageData call still fails or never arrives. Always pass an errorHandler, and use the queued/background APIs (transferUserInfo, updateApplicationContext, transferFile) for anything that must arrive. For genuine low-latency needs on a shared network, a direct HTTP/SSE channel is the honest escape hatch around Watch Connectivity’s reliability ceiling.
App Group required for complication updates
Section titled “App Group required for complication updates”Watch Connectivity delivers to the watchOS app’s process; WidgetKit reads from the widget extension’s own process. Bridge them:
- Enable App Groups on both the watchOS app target and the widget target (same group identifier).
- Write the incoming payload to
UserDefaults(suiteName: "group.com.yourco.app")or a file inside the shared container. - Call
WidgetCenter.shared.reloadTimelines(ofKind:)so the widget’sTimelineProviderre-reads the shared storage.
Design for the disconnected watch
Section titled “Design for the disconnected watch”| Configuration | Required behavior |
|---|---|
| Independent app on a Family Setup watch | No iPhone companion exists — WCSession never activates on the watch |
| Independent app, companion not installed | isWatchAppInstalled == false on iPhone — fall back to network/CloudKit |
| Paired watch, iPhone asleep or out of Bluetooth range | isReachable == false; queued transfers deliver on reconnection |
| LTE watch, iPhone off | isReachable == false; the app should still fetch directly over the network |
Concrete pattern: fetch primary data over URLSession or CloudKit. Only when activationState == .activated && isReachable == true, opportunistically refresh or cache via Watch Connectivity. Never block core content on a transfer arriving.
Common mistakes
Section titled “Common mistakes”| Mistake | Symptom | Fix |
|---|---|---|
Not calling setTaskCompletedWithSnapshot on every WKWatchConnectivityRefreshBackgroundTask |
App crashes at seemingly random times after a WC wake; background budget exhausted | Retain tasks, complete via handle(_:) and KVO on activationState/hasContentPending |
Using sendMessage as the primary sync path |
Silent failures when the companion is suspended; timeout errors | Switch to transferUserInfo (queued) or updateApplicationContext (latest-wins) |
| Watch Connectivity as the only data path | Family Setup watches show empty state; LTE watches away from iPhone show stale data | Fetch primary data via URLSession/CloudKit; use WC opportunistically |
Spamming transferCurrentComplicationUserInfo on every small change |
Silent throttling past 50/day; complication stops updating | Batch updates, check remainingComplicationUserInfoTransfers, use APNs widget push for high frequency |
| Updating a widget without an App Group | Transfer arrives but the widget never reflects it | Share via App Group UserDefaults/file; call WidgetCenter.shared.reloadTimelines(ofKind:) |
Missing sessionDidBecomeInactive/sessionDidDeactivate on iOS |
Second paired watch never receives data | Implement both; call activate() again inside sessionDidDeactivate |
| Activating the session before assigning a delegate | Activation callback fires before anything listens; dropped events | Assign the delegate first, then call activate() |
Assuming sendMessage errors fire at most once |
Duplicate retries or duplicated side effects | WC offers no exactly-once guarantee — tag messages with your own ID and dedupe |
Using updateApplicationContext when ordered delivery is needed |
Receiver misses events between wake intervals | Use transferUserInfo for anything where every event matters |
Not deleting files after transferFile completes |
Files accumulate on the sender’s disk | Remove the source file in session(_:didFinish:error:) |