HealthKit in SwiftUI: HRV, SpO2, Sleep and Workouts — A Practical Guide#
When I started building MeteoHealth — a wellbeing tracker that reads heart rate, heart rate variability, blood oxygen saturation, sleep stages and workouts — HealthKit looked like "just another framework for reading data." It isn't. HealthKit is a database with its own permission model, unit system, sample types and background-sync rules, and it's easy to get wrong exactly where users won't forgive mistakes: their private health data.
This article walks through a working approach to integrating HealthKit into a SwiftUI app — from authorization to the State of Mind API introduced in iOS 18. Every code sample here is a real pattern I use in MeteoHealth, minus UI and business-logic details.
Authorization: HKHealthStore and Data Types#
HealthKit doesn't grant "access to all of health" at once — an app requests specific types: HKQuantityType for quantitative values (heart rate, HRV, SpO2), HKCategoryType for categorical data (sleep stages), plus dedicated types for workouts and State of Mind.
One nuance worth internalizing early: HealthKit never tells you whether a user actually denied read access to a given type. requestAuthorization only confirms that the permission sheet was shown — not that access was granted. Your code has to behave correctly whether or not data comes back.
Here's the manager skeleton I start almost every health framework with:
import HealthKit
final class HealthKitManager {
static let shared = HealthKitManager()
private let healthStore = HKHealthStore()
// Types MeteoHealth reads: heart rate, HRV, SpO2, respiratory rate,
// sleep stages and workouts.
private let readTypes: Set<HKObjectType> = [
HKQuantityType(.heartRate),
HKQuantityType(.heartRateVariabilitySDNN),
HKQuantityType(.oxygenSaturation),
HKQuantityType(.respiratoryRate),
HKCategoryType(.sleepAnalysis),
HKObjectType.workoutType(),
HKSampleType.stateOfMindType()
]
// Types MeteoHealth writes: water, caffeine, heart rate from the
// camera-based PPG measurement, and State of Mind entries.
private let writeTypes: Set<HKSampleType> = [
HKQuantityType(.dietaryWater),
HKQuantityType(.dietaryCaffeine),
HKQuantityType(.heartRate),
HKSampleType.stateOfMindType()
]
func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthKitError.notAvailable
}
try await healthStore.requestAuthorization(toShare: writeTypes, read: readTypes)
}
}
enum HealthKitError: Error {
case notAvailable
}Notice the modern type syntax: HKQuantityType(.heartRate) instead of the old HKQuantityType.quantityType(forIdentifier: .heartRate)!. That force-unwrap was a long-running source of HealthKit crashes — the newer initializer removes it entirely.
The types MeteoHealth actually works with:
| Data | HealthKit type | Used for |
|---|---|---|
| Heart rate | HKQuantityType(.heartRate) | live chart, camera-based measurement |
| HRV (SDNN) | HKQuantityType(.heartRateVariabilitySDNN) | recovery index |
| SpO2 | HKQuantityType(.oxygenSaturation) | overnight readings from Apple Watch |
| Sleep | HKCategoryType(.sleepAnalysis) | REM/Core/Deep stages |
| Workouts | HKObjectType.workoutType() | activity feed |
| Mood | HKSampleType.stateOfMindType() | daily reflection |
Reading HRV and SpO2 with Async Descriptors#
With Swift Concurrency, HealthKit gained descriptor-based APIs — HKStatisticsQueryDescriptor and HKSampleQueryDescriptor — replacing nested completion handlers with a straightforward await. For MeteoHealth that's the difference between a 200-line loading screen and a 20-line one.
extension HealthKitManager {
/// Average HRV (SDNN, ms) for the last 24 hours.
func averageHRV(daysBack: Int = 1) async throws -> Double? {
let hrvType = HKQuantityType(.heartRateVariabilitySDNN)
let start = Calendar.current.date(byAdding: .day, value: -daysBack, to: Date())!
let predicate = HKQuery.predicateForSamples(withStart: start, end: Date())
let descriptor = HKStatisticsQueryDescriptor(
predicate: HKSamplePredicate.quantitySample(type: hrvType, predicate: predicate),
options: .discreteAverage
)
let statistics = try await descriptor.result(for: healthStore)
let unit = HKUnit.secondUnit(with: .milli)
return statistics?.averageQuantity()?.doubleValue(for: unit)
}
/// Latest blood oxygen saturation (SpO2) reading, as a percentage.
func latestSpO2() async throws -> Double? {
let spo2Type = HKQuantityType(.oxygenSaturation)
let descriptor = HKSampleQueryDescriptor(
predicates: [.quantitySample(type: spo2Type)],
sortDescriptors: [SortDescriptor(\.endDate, order: .reverse)],
limit: 1
)
let samples = try await descriptor.result(for: healthStore)
return samples.first?.quantity.doubleValue(for: .percent())
}
}A practical note on SpO2: Apple Watch Series 6 and later measures it episodically, mostly overnight, and readings can land hours after the watch syncs with the phone. If SpO2 shows "empty" right after installing the app, that's expected — not a broken authorization flow.
Sleep Stages and Workouts#
HKCategoryType(.sleepAnalysis) returns samples whose value is a raw Int that needs converting into HKCategoryValueSleepAnalysis. Starting with watchOS 9 / iOS 16, Apple Watch reports not just "asleep/awake" but actual stages — REM, Core, Deep:
extension HealthKitManager {
struct SleepStage {
let stage: HKCategoryValueSleepAnalysis
let start: Date
let end: Date
}
/// Sleep stages for the last night, sorted chronologically.
func lastNightSleepStages() async throws -> [SleepStage] {
let sleepType = HKCategoryType(.sleepAnalysis)
let start = Calendar.current.date(byAdding: .hour, value: -18, to: Date())!
let predicate = HKQuery.predicateForSamples(withStart: start, end: Date())
let descriptor = HKSampleQueryDescriptor(
predicates: [.categorySample(type: sleepType, predicate: predicate)],
sortDescriptors: [SortDescriptor(\.startDate, order: .forward)]
)
let samples = try await descriptor.result(for: healthStore)
return samples.compactMap { sample in
guard let value = HKCategoryValueSleepAnalysis(rawValue: sample.value) else {
return nil
}
return SleepStage(stage: value, start: sample.startDate, end: sample.endDate)
}
}
}HKCategoryValueSleepAnalysis | Meaning | How MeteoHealth uses it |
|---|---|---|
.asleepREM | REM sleep | share of REM in the overall sleep picture |
.asleepCore | Light sleep | baseline sleep duration |
.asleepDeep | Deep sleep | recovery-quality indicator |
.awake | Woke up during the night | counting nighttime interruptions |
.inBed | In bed, not necessarily asleep | distinct from actual sleep since iOS 18 |
Workouts are read with the same descriptor pattern, using a .workout() predicate on HKSampleQueryDescriptor — the code shape is identical to the SpO2 example, only the sample type changes.
Background Delivery: Updates Without Opening the App#
If your app needs to react to new data — say, refreshing a heart-rate widget or recomputing a recovery score every morning — reading data only when a screen opens isn't enough. HealthKit can wake your app via enableBackgroundDelivery and HKObserverQuery.
extension HealthKitManager {
/// Subscribes to background updates for heart rate and enables
/// hourly wake-ups even when MeteoHealth is not in the foreground.
func enableBackgroundDelivery() async throws {
let heartRateType = HKQuantityType(.heartRate)
try await healthStore.enableBackgroundDelivery(
for: heartRateType,
frequency: .hourly
)
let query = HKObserverQuery(sampleType: heartRateType, predicate: nil) { _, completionHandler, error in
defer { completionHandler() }
guard error == nil else { return }
Task {
try? await self.syncLatestHeartRate()
}
}
healthStore.execute(query)
}
private func syncLatestHeartRate() async throws {
// Fetch and cache the newest sample, refresh widgets, etc.
}
}There's a pitfall here I ran into myself early in MeteoHealth's development: completionHandler() in HKObserverQuery must be called every time, including on errors, or the system silently stops delivering updates to that observer over time — with no visible message in the console. The defer above isn't stylistic; it's insurance against exactly that bug.
Second point: enableBackgroundDelivery needs to be re-established on every app launch, not just once at first authorization — the subscription state isn't guaranteed to survive reinstalls and iOS updates.
State of Mind API: A New Layer of Wellbeing Data#
iOS 18 added an emotional-state API to HealthKit — HKStateOfMind, the same one behind the "State of Mind" section in the Health app. For MeteoHealth this turned out to be a natural extension of the mood logging the app already did: instead of a separate, isolated mood store, that data can now be written straight into HealthKit, where it becomes part of the user's overall health picture and, with permission, is available to other apps too.
extension HealthKitManager {
/// Saves a user-reported mood entry as a State of Mind sample.
func logStateOfMind(valence: Double, labels: [HKStateOfMind.Label]) async throws {
let sample = HKStateOfMind(
date: Date(),
kind: .momentaryEmotion,
valence: valence,
labels: labels,
associations: [.currentEvents]
)
try await healthStore.save(sample)
}
}valence is a scale from -1 (very unpleasant) to +1 (very pleasant), labels are specific tags like .calm, .stressed, .grateful, and associations describe context (work, family, health). One important restriction: HKStateOfMind has limited read access. Even with permission granted, an app can't read raw entries a user logged through the system Health app with the same fidelity the user sees them — Apple deliberately restricts third-party access to this sensitive category.
What Shipping MeteoHealth Actually Taught Me#
A few lessons that aren't obvious from the documentation alone:
- Don't treat
authorizationStatusas proof that data exists. It only reflects whether a request was made, not whether the user actually granted access — HealthKit intentionally hides that detail for privacy reasons. The only reliable way to know if data exists is to try reading it. - Units are their own source of bugs. HRV comes back in seconds (
HKUnit.secondUnit(with: .milli)), not intuitively in "milliseconds already." Mixing up the unit is easy, and the result fails silently — no exception is thrown. - Camera-based heart rate isn't a HealthKit feature — it's a custom algorithm. MeteoHealth's camera pulse measurement is built on PPG analysis of the camera feed, and the result is then written into HealthKit separately as a plain
HKQuantitySample. HealthKit itself never touches the camera. - Test against real Apple Watch data. The simulator lets you insert fake samples, but real fragmentation patterns — SpO2 in 15-minute bursts, HRV a few times a day — only show up on a real device paired to a real watch.
HealthKit is one of the few Apple frameworks where architectural discipline up front pays off many times over: data types, authorization and background delivery barely change across releases, while the cost of a mistake with private health data is reputational, not just technical.


