Streaming live heart rate from watch to phone
During a run, the heart rate you see on the phone comes from a sensor on the other wrist of the wireless link. Getting that number across — one beat per second, reliably, without flattening the battery or flooding the channel — is a surprisingly opinionated little problem. Here's how I solved it in iRunning.
The setup sounds trivial: the Apple Watch measures heart rate, the phone shows a big number that ticks live while you run. But the watch and the phone are two separate devices talking over Bluetooth (or peer Wi-Fi), and that link is allowed to be unreliable, slow, or simply asleep for stretches at a time. Everything interesting about this feature lives in the gap between "the watch knows your heart rate" and "the phone is showing it right now."
The watch is the source of truth
First principle, and it dictates everything downstream: the watch owns the data. It's the device with the sensor and the device running the HKWorkoutSession, so it keeps recording whether or not the phone is listening. The phone is a mirror — a nice live readout when the link is up, and nothing load-bearing depends on it. If the phone never gets a single sample, the workout is still complete and correct on the watch, which syncs to HealthKit at the end regardless.
Designing it this way removes a whole category of bugs. You're never trying to make the phone authoritative over data it doesn't generate, and you're never blocking the watch on a phone that wandered out of range.
Reading heart rate on the watch
Inside an active workout, the right tool is HKLiveWorkoutBuilder. You start a workout session, attach its builder, and the builder hands you samples through its delegate as they're collected — no polling, no timers. You filter the delegate callback for the heart-rate quantity type and pull the latest value out:
import HealthKit
final class WorkoutManager: NSObject, HKLiveWorkoutBuilderDelegate {
private let heartRateType = HKQuantityType(.heartRate)
private let bpmUnit = HKUnit.count().unitDivided(by: .minute())
func workoutBuilder(_ builder: HKLiveWorkoutBuilder,
didCollectDataOf types: Set<HKSampleType>) {
guard types.contains(heartRateType),
let stats = builder.statistics(for: heartRateType),
let quantity = stats.mostRecentQuantity() else { return }
let bpm = quantity.doubleValue(for: bpmUnit)
HRChannel.shared.send(bpm: bpm, at: Date())
}
}
If you're reading heart rate outside a workout — say a passive ambient view — the tool is HKAnchoredObjectQuery instead. You give it an anchor, it returns everything since that anchor, and its update handler keeps firing with only the new samples as they land. The anchor is the important part: it means you never re-process the same sample twice across launches. But for a running app, you're almost always inside a workout, so HKLiveWorkoutBuilder is the path. Either way, the output is the same — a fresh BPM value, a few times a second, that now needs to reach the phone.
Three transports, three jobs
This is where people get it wrong, because WCSession gives you three ways to move data and they are not interchangeable. Picking the wrong one is the difference between a readout that feels alive and one that lurches or drains the battery. The three:
sendMessage— live, immediate, lowest latency. It requires the counterpart to be reachable right now (session.isReachable == true), and if it isn't, the send fails. This is your real-time pipe.transferUserInfo— queued and guaranteed. The system holds each item and delivers it in FIFO order whenever the link next comes up, even across app launches. Delivery is certain; timing is not.updateApplicationContext— latest-state-only. It keeps exactly one dictionary; each call overwrites the last undelivered one. The counterpart gets the most recent state when it next wakes, and never sees the intermediate values.
Read those again with one question in mind — what happens to data I send while the link is down? sendMessage drops it. transferUserInfo keeps all of it and delivers in order. updateApplicationContext keeps only the newest. That single property is how you choose.
Why live HR is a sendMessage case
For a number that updates every second on screen, latency is the whole point — a heartbeat you see three seconds late is worse than useless, it's misleading. So the live path is sendMessage, fired the moment a new sample arrives while the phone is reachable.
But what about the value you produce while the phone isn't reachable? Here's the subtle bit: for a live readout you almost never want transferUserInfo as the fallback, even though it's the "guaranteed" one. Think about what guaranteed FIFO delivery actually means here — when the phone wakes up forty seconds later, it would receive forty queued heart-rate samples in a burst and replay them as if they were live. Nobody wants to watch their heart rate scrub through the last minute. For live readout, stale data is worse than no data.
So the fallback for the live number is updateApplicationContext: when the phone comes back, it gets the single most recent BPM and nothing else. The latest-state-only semantics are exactly right — you want the current value, not a history. The genuinely guaranteed transport, transferUserInfo, I reserve for things that must survive intact and where order matters: workout-complete events, the final summary, a marker the phone needs even if it was asleep the whole run. Match the transport to the meaning of the payload, not to a vague sense that "guaranteed is safer."
Use sendMessage for "show this now," updateApplicationContext for "here's the latest state," and transferUserInfo only for "this event must arrive, in order, eventually." Live heart rate is the first with a fallback to the second; workout boundaries are the third.
Throttle, or you'll flood the channel
HealthKit can hand you heart-rate updates faster than once a second, and your render loop certainly doesn't need more than ~1 Hz. If you call sendMessage on every single delegate callback, you'll saturate the link, spike power draw on both devices, and — counterintuitively — make the readout laggier, because messages queue up behind each other. The fix is to coalesce: keep only the most recent value and send at a fixed cadence.
import WatchConnectivity
final class HRChannel {
static let shared = HRChannel()
private let session = WCSession.default
private let minInterval: TimeInterval = 1.0 // ~1 Hz
private var lastSent = Date.distantPast
private var pending: Double?
func send(bpm: Double, at date: Date) {
pending = bpm
// Coalesce: at most one send per minInterval.
guard date.timeIntervalSince(lastSent) >= minInterval,
let value = pending else { return }
lastSent = date
pending = nil
let payload: [String: Any] = ["bpm": value, "t": date.timeIntervalSince1970]
if session.isReachable {
// Live path. Fall back to latest-state on failure.
session.sendMessage(payload, replyHandler: nil) { [weak self] _ in
self?.cacheLatest(payload)
}
} else {
// Phone asleep or away: keep only the newest value.
cacheLatest(payload)
}
}
private func cacheLatest(_ payload: [String: Any]) {
try? session.updateApplicationContext(payload)
}
}
Two details worth calling out. The pending value means a sample that arrives mid-interval isn't lost — it's held and sent at the next tick, so you always transmit the freshest reading rather than a stale one that happened to land on the boundary. And the sendMessage error handler doesn't just log; it falls back to caching the latest value, so a send that fails because reachability flipped mid-flight still leaves the right state waiting for the phone.
When the link is down, and when it returns
The phone being asleep or out of range — session.isReachable == false — is the normal case, not the edge case. Your wrist faces away, the phone's in a bag in the next room, the screen's off. The watch must keep recording through all of it without caring, which it does, because the workout lives on the watch.
What matters is the reconciliation when the link returns. With the design above it's almost free: the phone wakes, the system delivers the last application context, and the readout jumps straight to the current heart rate — not a replay of everything it missed. On the phone side you implement didReceiveApplicationContext for that catch-up value and didReceiveMessage for the live stream, and feed both into the same display. From the runner's perspective the number was simply "behind" for a moment and is now correct, which is exactly the honest thing to show.
The anything-that-must-arrive payloads ride transferUserInfo and need no special handling — the OS guarantees they land in order whenever it can, so the phone's record of the run is eventually complete even if it spent the entire workout in a pocket. You wire the listener once and trust the queue.
Designing for an unreliable link
Strip away the API names and the real lesson is about posture. A watch-to-phone connection is not a function call that always returns; it's a link that's frequently absent, occasionally slow, and only sometimes fast. Build as if reachability is the exception and you get a feature that degrades gracefully on its own — live when it can be, caught-up when it can't, and never blocking the device that actually owns the data. Pick each transport for what its payload means, throttle so you respect a channel you don't control, and let the watch stay the source of truth. Do that, and a flaky wireless link stops being a bug surface and becomes just another input you've designed around.