react-native-nitro-healthkit
v1.0.0
Published
React Native Nitro module for reading and writing health data — one TypeScript API over Apple HealthKit (iOS) and Android Health Connect.
Maintainers
Readme
react-native-nitro-healthkit 🏥
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.
✨ Features
- 🚀 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
📦 Installation
npm install react-native-nitro-healthkit
# or
yarn add react-native-nitro-healthkitiOS Setup
Minimum iOS deployment target: 14.0.
Install pods:
cd example/my-app/ios && pod installAdd 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
Android Setup
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.
🚀 Usage
Basic Example
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 Health Data for Multiple Days
// 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,
});Complete Component Example
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>
);
}📚 API Reference
requestAuthorization(): Promise<boolean>
Requests authorization to access HealthKit data.
Returns: Promise<boolean> - true if authorized, false otherwise
Example:
const authorized = await HealthKitModule.requestAuthorization();getSteps(startDate: Date, endDate: Date): Promise<number>
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')
);getHeartRate(startDate: Date, endDate: Date): Promise<number>
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')
);getHealthData(startDate: Date, endDate: Date): Promise<HealthData>
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')
);HealthData Interface
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).
🔍 Error Handling
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');
}🏗️ Architecture
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
Project Structure
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🤖 Android support matrix
| 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.
🌙 Background sync
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.
👀 Observers
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.
🧪 Testing
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/).
🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
📄 License
MIT © N0ku — see LICENSE.
🙏 Acknowledgments
- Built with Nitro Modules by @mrousavy
- Powered by Apple HealthKit and Android Health Connect
