7.5 — HealthKit
Opening scenario
The fitness team says: “Pull the user’s heart rate, today’s steps, and last night’s sleep — and let them log a workout.” HealthKit makes that possible, but the data model is wider than the entire rest of iOS. Every metric has a type, a unit, a source, an authorization status (granular per type, per direction), a quantity vs category vs correlation vs workout shape, and — crucially — a privacy model where Apple actively hides whether the user denied reads from you, to prevent inference attacks. Welcome to HealthKit, the most privacy-paranoid framework in the SDK.
| Context | What it usually means |
|---|---|
| Reads “HKHealthStore” | Has done basic reads |
| Reads “quantity / category / workout” | Knows the type taxonomy |
| Reads “HKObserverQuery / background delivery” | Has shipped background syncing |
| Reads “HKWorkoutSession” | Has built a watchOS workout app |
| Reads “deny-state privacy model” | Understands the read-status quirk |
Concept → Why → How → Code
Concept
HKHealthStore is the single gateway to the Health database — a system-managed SQLite store on the device that’s encrypted, syncs end-to-end through iCloud to other devices, and is never shared with Apple servers. Your app requests read/write permission per type; the user can grant a subset or none.
Three data shapes:
- Quantity samples: numeric measurements with units (steps, heart rate, body mass).
- Category samples: enum-like states with optional duration (sleep analysis, menstruation, headache severity).
- Workouts: a special
HKWorkoutwith type, duration, energy, distance, and child samples.
Why
- Single source of truth the user already trusts.
- Cross-device sync for free via iCloud.
- Permission UX the user already understands from the Health app.
- Watch integration: HealthKit data flows seamlessly between iPhone and Apple Watch.
How — entitlement & setup
- Project → Signing & Capabilities → + Capability → HealthKit.
- Tick Clinical Health Records only if you read FHIR records (extra App Review).
Info.plist:NSHealthShareUsageDescription— what reads will be used for.NSHealthUpdateUsageDescription— what writes will be used for.
import HealthKit
actor HealthService {
let store = HKHealthStore()
func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable() else {
throw HKError(.errorHealthDataUnavailable)
}
let read: Set = [
HKQuantityType(.heartRate),
HKQuantityType(.stepCount),
HKQuantityType(.activeEnergyBurned),
HKCategoryType(.sleepAnalysis),
HKObjectType.workoutType()
]
let write: Set = [
HKQuantityType(.bodyMass),
HKObjectType.workoutType()
]
try await store.requestAuthorization(toShare: write, read: read)
}
}
Reading quantities
extension HealthService {
func todaySteps() async throws -> Double {
let type = HKQuantityType(.stepCount)
let cal = Calendar.current
let start = cal.startOfDay(for: .now)
let predicate = HKQuery.predicateForSamples(withStart: start, end: .now)
return try await withCheckedThrowingContinuation { cont in
let query = HKStatisticsQuery(
quantityType: type,
quantitySamplePredicate: predicate,
options: .cumulativeSum
) { _, stats, error in
if let error { cont.resume(throwing: error); return }
let sum = stats?.sumQuantity()?.doubleValue(for: .count()) ?? 0
cont.resume(returning: sum)
}
store.execute(query)
}
}
}
Reading category samples (sleep)
func sleepLastNight() async throws -> TimeInterval {
let type = HKCategoryType(.sleepAnalysis)
let cal = Calendar.current
let now = Date.now
let start = cal.date(byAdding: .hour, value: -16, to: now)!
let predicate = HKQuery.predicateForSamples(withStart: start, end: now)
return try await withCheckedThrowingContinuation { cont in
let query = HKSampleQuery(sampleType: type, predicate: predicate,
limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
if let error { cont.resume(throwing: error); return }
let asleep = (samples as? [HKCategorySample] ?? [])
.filter { $0.value == HKCategoryValueSleepAnalysis.asleepCore.rawValue ||
$0.value == HKCategoryValueSleepAnalysis.asleepDeep.rawValue ||
$0.value == HKCategoryValueSleepAnalysis.asleepREM.rawValue }
.reduce(0.0) { $0 + $1.endDate.timeIntervalSince($1.startDate) }
cont.resume(returning: asleep)
}
store.execute(query)
}
}
Writing a workout
func saveWalkout(start: Date, end: Date, distanceMeters: Double, kcal: Double) async throws {
let workout = HKWorkout(
activityType: .walking,
start: start,
end: end,
duration: end.timeIntervalSince(start),
totalEnergyBurned: HKQuantity(unit: .kilocalorie(), doubleValue: kcal),
totalDistance: HKQuantity(unit: .meter(), doubleValue: distanceMeters),
metadata: nil
)
try await store.save(workout)
}
Background delivery
For “wake my app when new heart-rate data lands” semantics, combine HKObserverQuery with enableBackgroundDelivery:
func startWatchingHeartRate() async throws {
let type = HKQuantityType(.heartRate)
try await store.enableBackgroundDelivery(for: type, frequency: .immediate)
let observer = HKObserverQuery(sampleType: type, predicate: nil) { _, completion, error in
if let error { completion(); return }
Task {
// Re-query to get the new samples and process
completion()
}
}
store.execute(observer)
}
Even with .immediate, the OS coalesces; expect bursts every few minutes, not the millisecond a sample lands.
The deny-state quirk
When the user denies read access, HealthKit does not tell you. Queries simply return empty. This is intentional — if your app could see “denied” vs “no data,” it could infer the user has the metric but is hiding it. Workaround pattern: present the permission sheet, then always show data with a graceful “No data yet” state. Don’t try to detect a denial; provide a “Re-check permissions” button that re-runs requestAuthorization.
Statistics collections
For chart data (e.g., 7 days of step totals):
let type = HKQuantityType(.stepCount)
let cal = Calendar.current
let end = cal.startOfDay(for: .now)
let start = cal.date(byAdding: .day, value: -6, to: end)!
let interval = DateComponents(day: 1)
let query = HKStatisticsCollectionQuery(
quantityType: type,
quantitySamplePredicate: HKQuery.predicateForSamples(withStart: start, end: end),
options: .cumulativeSum,
anchorDate: end,
intervalComponents: interval
)
query.initialResultsHandler = { _, stats, _ in
stats?.enumerateStatistics(from: start, to: end) { stat, _ in
let day = stat.startDate
let count = stat.sumQuantity()?.doubleValue(for: .count()) ?? 0
// collect
}
}
store.execute(query)
In the wild
- Apple Fitness / Health — the reference apps.
- Strava, Nike Run Club, Peloton — write workouts, read heart rate during sessions.
- AutoSleep, Sleep Cycle — read/write
sleepAnalysiscategory samples. - MyFitnessPal — writes
dietaryEnergyConsumed, readsbodyMass. - Apollo Neuro, Calm — write
mindfulSessioncategory samples for meditation tracking.
Common misconceptions
- “
requestAuthorizationreturns whether the user granted each type.” It returns the user-prompt-completed signal for writes. For reads, you cannot know what was granted — see the deny-state quirk above. - “HealthKit syncs to iCloud Drive.” It syncs to the user’s iCloud Health backup (separate, end-to-end encrypted), not iCloud Drive. Your app cannot access another device’s Health database except through the same user’s HealthKit.
- “HealthKit works on iPad.” Limited support landed in iPadOS 17. Older iPads cannot host HealthKit; sample apps should guard with
HKHealthStore.isHealthDataAvailable(). - “I can show a hospital’s records via HealthKit.” Only via the Clinical Records (FHIR) API, which requires an additional entitlement and App Review explanation.
- “Background delivery wakes me instantly.” It wakes you when the OS feels like it. Don’t design UX assuming sub-second latency from sensor read to app notification.
Seasoned engineer’s take
HealthKit is the framework where you most need to read the user agreement before writing code. Apple is very specific: you may not sell HealthKit-derived data, use it for advertising, store it on your servers without explicit consent, or copy it to non-Apple cloud backups. App Review checks the privacy policy URL on every release; vague language gets you rejected. Plan the privacy story before the API call.
Engineering-wise:
- Treat HKHealthStore as I/O-bound and async-only. Wrap legacy callback queries in
withCheckedThrowingContinuationonce; never touch the callbacks again. - Cache aggressively. Health database queries can take 100ms+; for the same window users open repeatedly, cache the rolled-up number.
- Always handle the empty case. Because deny looks like no data, every chart needs a graceful “Allow Health access in Settings →” path.
TIP: Use
HKStatisticsCollectionQuerywith.cumulativeSumand a daily interval anchor to compute weekly/monthly summaries server-style on-device. It’s an order of magnitude faster thanHKSampleQuery+ map-reducing in Swift.
WARNING: Never write to types your app doesn’t own conceptually. Writing fake heart-rate samples to “make charts look populated” pollutes the user’s permanent health record across all apps. App Review rejects this on sight.
Interview corner
Junior: “How do you get today’s step count?”
Request read authorization for
HKQuantityType(.stepCount), runHKStatisticsQuerywith a predicate fromstartOfDayto now, options.cumulativeSum. Readstats.sumQuantity()?.doubleValue(for: .count()).
Mid: “How do you get notified when new heart-rate samples arrive while the app is in the background?”
Enable background delivery via
enableBackgroundDelivery(for:type, frequency: .immediate). Register anHKObserverQuerywith completion handler; the OS wakes the app and calls it when new samples land. In the handler, run a follow-upHKSampleQueryfrom the last seen anchor to get the new data, persist, and call the completion. Background delivery requires the HealthKit background mode entitlement.
Senior: “Design a sleep coach app that reads sleep stages, writes mindfulness session correlations, and respects the user’s permission denials gracefully.”
Two phases. (1) Permission UX: show a single sheet listing what you read (sleep) and write (mindful sessions) with one-sentence justifications; on dismiss, you cannot know what was granted, so render a “Connect Health” state if today’s query returns empty. Provide a “Re-check” button that re-runs
requestAuthorization(cheap if already granted, prompts if not). (2) Data layer: actor-wrapped HKHealthStore, anchored queries viaHKAnchoredObjectQueryfor incremental sync, statistics-collection queries for chart data. On wakeup from observer, batch-process new samples and write a correlatedmindfulSessionif the sleep window matches a logged meditation. Persist the anchor per type in Keychain (not UserDefaults — survives migrations). Privacy: never POST raw samples to a server without an explicit “Sync to coach” opt-in; if synced, only aggregate scores, not raw timestamps.
Red flag: “We check if authorizationStatus(for:) returns .sharingDenied for reads, and if so, show an error.”
That call returns the write status. For reads, Apple intentionally returns
.notDeterminedeven after a denial. Demonstrates the candidate hasn’t read Apple’s privacy documentation and will ship a broken UX that “works in dev” because the dev granted everything.
Lab preview
Health is foundational background reading for the broader ecosystem; the Lab 7.2 — Widget extension optionally includes a stretch goal that surfaces step count from HealthKit on the widget via App Group caching.
Next: 7.6 — StoreKit 2