Easy stream heart rate from WatchOS to Ionic

In my previous post I showed how to connect the watchOS with a Capacitor based application. This time I want to show how to easily grab HR using HealthKit from watchOS and stream it into Ionic application.

The project in this post will be based upon the project on the previous post. If you don’t know how to connect the Ionic app to Apple Watch I recommend reading this post.

tldr; whole example is available here 



First add a HealthKit capability to the Watch.

Background delivery is required for long-running background observers. In this example we don’t truly need this.

Accessing data requires permissions from the user, but before asking for permissions add proper keys in the *.plist file:

  1. „Privacy – Health Update Usage Description”
  2. „Privacy – Health Share Usage Description”

Now let’s create a class that request permissions to access heart rate data. This is essential for our project so just add it to init method in ExtensionDelegate.

let permissions = Set([HKObjectType.quantityType(forIdentifier: .heartRate)!])
let status = store.requestAuthorization(toShare: permissions , read: permissions) { (success, error) in

In real scenario you may want to handle success or error depending on status of authorization request. Make sure to let the user know why you need this, before making any requests.

Create query

To get the data from HealthKit we need to build a query. HealthKit provides a way to build queries that are one of two types: single use or long-running. We need a long running query that will watch for any new records and show them instantly in the app.

 private var healthStore = HKHealthStore()
 let heartRateQuantity = HKUnit(from: "count/min")
 private func watchHeartRate() {
        let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate)!
        let devicePredicate = HKQuery.predicateForObjects(from: [HKDevice.local()])
        let updateHandler: (HKAnchoredObjectQuery, [HKSample]?, [HKDeletedObject]?, HKQueryAnchor?, Error?) -> Void = {
            query, samples, deletedObjects, queryAnchor, error in
            guard let samples = samples as? [HKQuantitySample] else {
            self.process(samples, type: .heartRate)
        let query = HKAnchoredObjectQuery(type: HKObjectType.quantityType(forIdentifier: .heartRate)!, predicate: devicePredicate, anchor: nil, limit: HKObjectQueryNoLimit, resultsHandler: updateHandler)
        query.updateHandler = updateHandler

To build a query we need to select what type of data we are looking for. We focus only on heart rate of the user. There are also predicates that let limit the data for example based on the time. Once predicate and type are selected, we create an HKAnchoredObjectQuery that will continuously deliver new results called samples. Each sample package will be passed to an update handler. It’d be good to process it then in some way.

private func process(_ samples: [HKQuantitySample], type: HKQuantityTypeIdentifier) {
        if !samples.isEmpty {
            let sample = samples.last!
            if type == .heartRate {
                self.value = Int(sample.quantity.doubleValue(for: heartRateQuantity))
                if !WCSession.default.isReachable {
                    WCSession.default.sendMessage(["heartRate": self.value], replyHandler: nil)
                } else {
                    WCSession.default.transferUserInfo(["heartRate": self.value])

Processing is very trivial. We simply pick the latest sample and set it as a current value. After that we send it through WCSession to the app in iOS.

More details on building queries can be found here.

Watch version UI

Finally let’s add a UI on the Watch to compare the results on watch against the app version.

@State private var value = 0
var body: some View {
        Text("❤️ \(value)")
            .font(.system(size: 50))
    .onAppear(perform: watchHeartRate)

No need for fireworks. Just display the latest heart value.

That’s all for our Watch part.

Note: this example is not good for real scenario. In real scenario you should use a HRWorkoutSession to get proper data and stop it once it’s finished.


Now we need to handle the data sent from the Watch. We are going to mostly reuse the code from the previous post, except changing the properties. First update the Notification name.

extension NSNotification.Name {
    static let onHeartRateValueUpdated = Notification.Name("onHeartRateValueUpdated")

Then update WCSession handlers.

    func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
        let value = message["heartRate"] as? Int
        NotificationCenter.default.post(name: .onHeartRateValueUpdated, object: value)
    func session(_ session: WCSession, didReceiveUserInfo message: [String: Any]) {
        let value = message["heartRate"] as? Int
        NotificationCenter.default.post(name: .onHeartRateValueUpdated, object: value)

In case we receive a message or a user info (Not reachable) we pass it to the NotificationCenter. Now we can add a handler in the plugin method.

    func observeHR(_ call: CAPPluginCall) {
        call.keepAlive = true
        NotificationCenter.default.addObserver(forName: .onHeartRateValueUpdated, object: nil, queue: .main) {  notification in
            call.resolve(["heartRate": notification.object ])

For simplicity this code ignores all subscriptions and call handling, something you should never forget about in real app.


Finally update the code of the Ionic part to support a new method and update UI if any change happens.

<!-- app.component.html -->
    Watch OS integration
    <h2>Connection status: {{status}}</h2>
    <h3>Heart rate: {{hr}}</h3>
    <ion-button (click)="subscribe()">Subscribe</ion-button>
    <ion-button (click)="checkStatus()">Check status</ion-button>

Update the plugin interface and plugin service responsible for Watch communication.

import { Injectable } from '@angular/core';
import { CallbackID, Capacitor, registerPlugin } from '@capacitor/core';

export enum WatchOsStatus {
  OK = 0,
  NotSupported = 1,
  NotReachable = 2,
  NotPaired = 3,
  CommunicationProblem = 4,
  ActivationFailure = 5,
  WatchAppNotInstalled = 6,

export type ObserveHRCallback = (
  message: { heartRate: number } | null,
  err?: any
) => void;

const _pluginName: string = 'WatchOSPlugin';

export interface WatchOSPlugin {
  getState(): Promise<{
    status: WatchOsStatus;
  observeHR(callback: ObserveHRCallback): Promise<CallbackID>;

const WatchOSPlugin = registerPlugin<WatchOSPlugin>(_pluginName);
  providedIn: 'root',
export class WatchOSSevice {
  async getState(): Promise<WatchOsStatus> {
    if (!Capacitor.isPluginAvailable(_pluginName)) {
      return WatchOsStatus.NotSupported;
    const result = await WatchOSPlugin.getState();
    return result ? result.status : WatchOsStatus.CommunicationProblem;

  async observeHR(callback: ObserveHRCallback): Promise<CallbackID | null> {
    if (Capacitor.isPluginAvailable(_pluginName)) {
      return await WatchOSPlugin.observeHR(callback);
    return null;

and small updates for the main component.

export class AppComponent implements OnInit {
  value: string = '';
  status: WatchOsStatus | null = null;
  hr: any = 0;
    private watchOsService: WatchOSSevice,
    private cd: ChangeDetectorRef
  ) {}

  async ngOnInit() {
    this.status = await this.watchOsService.getState();

  async checkStatus() {
    this.status = await this.watchOsService.getState();

  async subscribe() {
    await this.watchOsService.observeHR((result) => {
      this.hr = result?.heartRate ?? 0;

That’s it. Now give it a try. Remember to try on a real device as HealthKit as at the moment of writing this post there’s no option to simulate Heart rate in WatchOS simulator.


This post shows that you can use Ionic to access many areas of iOS & WatchOS development. There’s a simple and fast way to transfer data from WatchOS to Ionic application. In real example you can create a HRWorkoutSession to watch for specific properties and keep the watch better balanced in terms of background processing and battery level.