A high-performance React Native library exposing a single TypeScript API on top of Apple HealthKit (iOS) and Android Health Connect (Android) via Nitro Modules. Built with Swift + Kotlin and powered by modern C++ interop for native performance.
Cross-platform. Health Connect is the canonical Android health data API; everything that writes into it (Samsung Health, Google Fit, Fitbit since 2024, Withings, Oura, MyFitnessPal, β¦) is readable through this module on Android. See the Android support matrix below.
- π Ultra-fast: Built with Nitro Modules for native performance
- π Cross-platform: One TypeScript API, two native implementations (iOS Swift + Android Kotlin)
- π Comprehensive: Steps, heart rate, active energy, distance, floors, sleep, workouts, and ~130 quantity / 70 category types
- π Privacy-first: Proper HealthKit & Health Connect authorization flows
- π± iOS Native: Pure Swift implementation with HealthKit framework
- π€ Android Native: Pure Kotlin implementation on top of
androidx.health.connect:connect-client - βοΈ Writes:
writeQuantityData/writeCategoryData(insert manual samples) - π Observers:
observeQuantityChanges/observeCategoryChanges - π Background sync: register a periodic background job (Android WorkManager / iOS
BGTaskScheduler) that POSTs deltas to your backend - π― Type-safe: Full TypeScript support
- β‘ Promise-based: Modern async/await API
npm install react-native-nitro-healthkit
# or
yarn add react-native-nitro-healthkitMinimum iOS deployment target: 14.0.
-
Install pods:
cd example/my-app/ios && pod install
-
Add HealthKit capability:
- Open your Xcode project
- Select your target β Signing & Capabilities
- Click "+ Capability" and add "HealthKit"
-
Add privacy descriptions to
Info.plist(NSHealthShareUsageDescriptionis required to read;NSHealthUpdateUsageDescriptionis required only if you callwriteQuantityData/writeCategoryData):<key>NSHealthShareUsageDescription</key> <string>We need access to your health data to track your activity</string> <key>NSHealthUpdateUsageDescription</key> <string>We need access to your health data to track your activity</string>
-
Ensure entitlements are set. Your
*.entitlementsfile should contain:<key>com.apple.developer.healthkit</key> <true/>
For
observeQuantityChanges/observeCategoryChangesto fire while the app is backgrounded, also add the background-delivery entitlement:<key>com.apple.developer.healthkit.background-delivery</key> <true/>
-
Background sync only (skip if you don't call
registerBackgroundSync). iOS requires the background-task handler to be registered at launch, beforeapplication(_:didFinishLaunchingWithOptions:)returns:- Declare the task identifier and background modes in
Info.plist:<key>BGTaskSchedulerPermittedIdentifiers</key> <array> <string>com.nitrohealthkit.sync</string> </array> <key>UIBackgroundModes</key> <array> <string>fetch</string> <string>processing</string> </array>
- Register the launch handler from your
AppDelegate:import NitroHealthkit // Swift func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: ...) -> Bool { HealthKitBackgroundSync.registerLaunchHandler() // ... }
// Objective-C AppDelegate #import <NitroHealthkit/NitroHealthkit-Swift.h> [HealthKitBackgroundSync registerLaunchHandler];
iOS decides when to actually run the task β
intervalMinutesis a lower bound, not a guarantee. - Declare the task identifier and background modes in
Health Connect ships in the platform on Android 14+. On Android 8β13, users install the "Health Connect" app from the Play Store; the module detects its presence via HealthConnectClient.getSdkStatus and surfaces it via isHealthKitAvailable().
-
Minimum SDK:
26(Android 8.0). The module'sbuild.gradledefaults match. -
Declare permissions in your host app's
AndroidManifest.xml(or viaapp.config.tsif you use Expo prebuild). The library's own manifest declares the same set so it merges naturally.<uses-permission android:name="android.permission.health.READ_STEPS" /> <uses-permission android:name="android.permission.health.WRITE_STEPS" /> <uses-permission android:name="android.permission.health.READ_HEART_RATE" /> <uses-permission android:name="android.permission.health.READ_SLEEP" /> <uses-permission android:name="android.permission.health.READ_EXERCISE" /> <uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" /> <uses-permission android:name="android.permission.health.READ_DISTANCE" /> <uses-permission android:name="android.permission.health.READ_FLOORS_CLIMBED" /> <!-- β¦and the WRITE_* counterparts if you call writeQuantityData / writeCategoryData -->
-
Add the Health Connect rationale intent filter to your main
Activity(Google requires this β without it, the system permission dialog won't show a link back to your app):<activity android:name=".MainActivity" β¦> <intent-filter> <action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" /> </intent-filter> </activity>
-
First-run flow:
requestAuthorization()on Android only reports whether the user has already granted the default set of permissions β requesting them needs an Activity, so it must be triggered from your UI layer:val requestPermissions = registerForActivityResult( PermissionController.createRequestPermissionResultContract() ) { granted -> /* update state */ } requestPermissions.launch(setOf( HealthPermission.getReadPermission(StepsRecord::class), HealthPermission.getReadPermission(HeartRateRecord::class), // ... etc. ))
On the JS side, the host app handles this via
react-native-permissionsor a thin Kotlin glue Activity β the module deliberately does not own this flow because the choice of UI is host-app territory. -
Production launch: for each
WRITE_*permission you ship, Google Play asks for a written justification (review takes ~3 business days). Plan accordingly.
import { HealthKitModule } from 'react-native-nitro-healthkit';
// Request authorization
const authorized = await HealthKitModule.requestAuthorization();
if (authorized) {
// Get today's steps
const today = new Date();
const startOfDay = new Date(today.setHours(0, 0, 0, 0));
const steps = await HealthKitModule.getSteps(startOfDay, new Date());
console.log(`Steps today: ${steps}`);
}// Get last 7 days of health data
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 7);
const healthData = await HealthKitModule.getHealthData(startDate, endDate);
console.log('Health Data:', {
steps: healthData.steps,
heartRate: healthData.heartRate,
activeEnergy: healthData.activeEnergy,
distance: healthData.distance,
});import React, { useState } from 'react';
import { View, Button, Text, Alert } from 'react-native';
import { HealthKitModule, type HealthData } from 'react-native-nitro-healthkit';
export default function HealthScreen() {
const [isAuthorized, setIsAuthorized] = useState(false);
const [healthData, setHealthData] = useState<HealthData | null>(null);
const requestPermissions = async () => {
try {
const authorized = await HealthKitModule.requestAuthorization();
setIsAuthorized(authorized);
if (!authorized) {
Alert.alert('Authorization denied');
}
} catch (error) {
Alert.alert('Error', `${error}`);
}
};
const fetchHealthData = async () => {
if (!isAuthorized) {
Alert.alert('Please authorize HealthKit first');
return;
}
try {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 7);
const data = await HealthKitModule.getHealthData(startDate, endDate);
setHealthData(data);
} catch (error) {
Alert.alert('Error', `${error}`);
}
};
return (
<View>
<Button
title="Request Authorization"
onPress={requestPermissions}
/>
{isAuthorized && (
<Button
title="Fetch Health Data (7 days)"
onPress={fetchHealthData}
/>
)}
{healthData && (
<View>
{healthData.steps && (
<Text>Steps: {Math.round(healthData.steps)}</Text>
)}
{healthData.heartRate && (
<Text>Heart Rate: {Math.round(healthData.heartRate)} bpm</Text>
)}
</View>
)}
</View>
);
}Requests authorization to access HealthKit data.
Returns: Promise<boolean> - true if authorized, false otherwise
Example:
const authorized = await HealthKitModule.requestAuthorization();Gets the total step count for the specified period.
Parameters:
startDate: Date- Start of the periodendDate: Date- End of the period
Returns: Promise<number> - Total steps count
Example:
const steps = await HealthKitModule.getSteps(
new Date('2025-10-17'),
new Date('2025-10-24')
);Gets the average heart rate for the specified period.
Parameters:
startDate: Date- Start of the periodendDate: Date- End of the period
Returns: Promise<number> - Average heart rate in BPM
Example:
const bpm = await HealthKitModule.getHeartRate(
new Date('2025-10-17'),
new Date('2025-10-24')
);Gets comprehensive health data for the specified period. This method fetches all available metrics in parallel and returns partial data if some metrics are unavailable.
Parameters:
startDate: Date- Start of the periodendDate: Date- End of the period
Returns: Promise<HealthData> - Object containing available health metrics
Example:
const data = await HealthKitModule.getHealthData(
new Date('2025-10-17'),
new Date('2025-10-24')
);interface HealthData {
steps?: number; // Total steps count
heartRate?: number; // Average heart rate (BPM)
activeEnergy?: number; // Active energy burned (kcal)
distance?: number; // Distance traveled (meters)
sleepAnalysis?: string; // Sleep summary
}
getHealthDatacurrently populatesstepsandheartRate; the other fields are reserved on the interface. To read active energy, distance or sleep today, usegetAggregatedQuantity/getQuantityData(e.g.ACTIVE_ENERGY_BURNED,DISTANCE_WALKING_RUNNING) andgetCategoryData(SLEEP_ANALYSIS).
The library handles errors gracefully. If a specific metric is unavailable (e.g., no heart rate data), other metrics will still be returned:
const data = await HealthKitModule.getHealthData(startDate, endDate);
// Even if heart rate data is unavailable, steps will be returned
if (data.steps) {
console.log(`Steps: ${data.steps}`);
}
if (!data.heartRate) {
console.log('No heart rate data available');
}This library is built with Nitro Modules, providing:
- Native performance: Direct Swift/C++ implementation
- Type safety: Full TypeScript definitions generated from native specs
- Modern APIs: Promise-based async/await interface
- Zero-copy: Efficient data passing between JavaScript and native
packages/
βββ ios/ # Swift HealthKit implementation
β βββ HealthKitModule.swift
β βββ CacheManager.swift
β βββ Observers/HealthKitObserverManager.swift # HKObserverQuery + background delivery
β βββ BackgroundSync/ # BGTaskScheduler + Keychain
β β βββ HealthKitBackgroundSync.swift
β β βββ KeychainCredentialsStore.swift
β βββ NitroHealthkitObjcBridge.swift
βββ android/ # Kotlin Health Connect implementation
β βββ build.gradle
β βββ src/main/kotlin/io/github/n0ku/nitrohealthkit/
β βββ HealthKitModule.kt # extends HybridHealthKitSpec
β βββ cache/CacheManager.kt # parity with iOS CacheManager
β βββ mappers/ # HK β Health Connect mapping
β β βββ QuantityMapper.kt
β β βββ CategoryMapper.kt
β β βββ WorkoutMapper.kt
β βββ observers/ChangesObserver.kt # Health Connect changes API
β βββ workers/HealthSyncWorker.kt # WorkManager periodic sync
β βββ auth/SecureCredentialsStore.kt # EncryptedSharedPreferences-backed
β βββ util/ # TimeRangeHelper, context holder
βββ src/
β βββ index.ts # JS entry point + types
β βββ specs/Example.nitro.ts # Nitro spec (source of truth)
βββ nitrogen/ # Generated Swift / Kotlin / C++ stubs
βββ lib/ # Compiled JavaScript
| HealthKit type | Android record | Read | Write | Notes |
|---|---|---|---|---|
STEPS |
StepsRecord |
β | β | aggregate via StepsRecord.COUNT_TOTAL |
HEART_RATE |
HeartRateRecord (samples) |
β | β | per-sample flatten |
RESTING_HEART_RATE |
RestingHeartRateRecord |
β | β | |
HEART_RATE_VARIABILITY_SDNN |
HeartRateVariabilityRmssdRecord |
β | β | HC has RMSSD, not SDNN β best-effort |
ACTIVE_ENERGY_BURNED |
ActiveCaloriesBurnedRecord |
β | β | kcal |
BASAL_ENERGY_BURNED |
BasalMetabolicRateRecord |
β | β | kcal/day |
DIETARY_ENERGY_CONSUMED |
TotalCaloriesBurnedRecord |
β | β | |
DISTANCE_* |
DistanceRecord |
β | β | metres |
FLIGHTS_CLIMBED |
FloorsClimbedRecord |
β | β | |
BODY_MASS / HEIGHT / BODY_FAT_PERCENTAGE / LEAN_BODY_MASS |
WeightRecord / HeightRecord / BodyFatRecord / LeanBodyMassRecord |
β | β | |
BLOOD_GLUCOSE / BLOOD_PRESSURE_* / BLOOD_OXYGEN_SATURATION |
BloodGlucoseRecord / BloodPressureRecord / OxygenSaturationRecord |
β | β | |
BODY_TEMPERATURE |
BodyTemperatureRecord |
β | β | |
RESPIRATORY_RATE |
RespiratoryRateRecord |
β | β | |
VO2_MAX |
Vo2MaxRecord |
β | β | |
WALKING_SPEED / RUNNING_SPEED |
SpeedRecord (samples) |
β | β | m/s |
RUNNING_POWER / CYCLING_POWER |
PowerRecord (samples) |
β | β | watts |
DIETARY_WATER |
HydrationRecord |
β | β | litres |
SLEEP_ANALYSIS |
SleepSessionRecord.stages |
β | β | stages unrolled into one sample each |
MENSTRUAL_FLOW / INTERMENSTRUAL_BLEEDING / OVULATION_TEST_RESULT / CERVICAL_MUCUS_QUALITY / SEXUAL_ACTIVITY |
matching HC records | β | partial | |
Workouts (getWorkouts) |
ExerciseSessionRecord |
β | β | workoutActivityType aligned with HKWorkoutActivityType raw values |
Apple-only (APPLE_EXERCISE_TIME, APPLE_STAND_HOUR, MINDFUL_SESSION, HANDWASHING_EVENT, β¦) |
β | β | β | returns empty list + logs warning. Use ExerciseSessionRecord for active time. |
Reading an Apple-only type or a type Health Connect doesn't model returns
[](andwriteQuantityDatareturnsfalse) β the module never throws for unsupported types so cross-platform code keeps working.
import { HealthKitModule } from 'react-native-nitro-healthkit';
// After login β the native job pulls deltas periodically and POSTs them.
await HealthKitModule.registerBackgroundSync({
apiBaseUrl: 'https://api.example.com',
jwtToken: '<user JWT>',
intervalMinutes: 15,
types: ['HKQuantityTypeIdentifierStepCount', 'HKQuantityTypeIdentifierHeartRate'],
syncPath: '/users/health-data',
});
// On logout β stops the job AND wipes the stored credentials at rest.
await HealthKitModule.unregisterBackgroundSync();
// Optional: is a sync currently registered?
const active = await HealthKitModule.isBackgroundSyncRegistered();Both platforms POST {apiBaseUrl}{syncPath} with Authorization: Bearer <jwt> and a body of
{ source, syncedAt, entries: [{ type, samples: [...] }] } containing the new samples since the last
successful checkpoint, and clear the stored credentials on a 401/403 response.
- Android: a WorkManager
PeriodicWorkRequest(floor of 15 min). Credentials live inEncryptedSharedPreferences(AES-256-GCM, MasterKey backed by the Android Keystore). - iOS: a
BGTaskSchedulerapp-refresh task (identifiercom.nitrohealthkit.sync). Credentials live in the Keychain (kSecAttrAccessibleAfterFirstUnlock). Requires the one-time launch registration andInfo.plistentries described in iOS Setup β without them the call stores credentials but the OS never runs the task. iOS schedules opportunistically, so runs are best-effort, not guaranteed at a fixed interval.
const sub = await HealthKitModule.observeQuantityChanges(
HealthKitQuantityType.STEPS,
(token) => {
// Re-fetch what you need; the token is opaque (Health Connect's changes cursor).
void HealthKitModule.getQuantityData(/* ... */);
},
);
// later β¦
await HealthKitModule.removeObserver(sub);
// or drop every active subscription at once
await HealthKitModule.removeAllObservers();The token passed to your callback is opaque and platform-specific (Health Connect's changes cursor
on Android, a serialized HKQueryAnchor on iOS) β treat it as a "something changed" signal and
re-fetch what you need. On iOS, observers use HKObserverQuery with background delivery (add the
com.apple.developer.healthkit.background-delivery entitlement to keep them firing while
backgrounded). On Android, Health Connect has no push channel, so the Kotlin side runs a
30-second polling coroutine per subscription.
An example app is included in the example/ directory:
cd example/my-app
npm install
npx expo run:ios # iOS
npx expo run:android # Android (Health Connect must be installed/active on the device)From the repo root the Makefile exposes:
make test # TS (jest) + Kotlin (gradle)
make test-ts
make test-android # ./gradlew :react-native-nitro-healthkit:testCI runs both suites on every push/PR β see .github/workflows/ci.yml. After editing src/specs/Example.nitro.ts, regenerate the Swift/Kotlin/C++ stubs with make nitrogen (or npm run specs inside packages/).
Contributions are welcome! Please feel free to submit a Pull Request.
MIT Β© N0ku β see LICENSE.
- Built with Nitro Modules by @mrousavy
- Powered by Apple HealthKit and Android Health Connect
- π Report an issue
- π¬ Discussions