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.

ContextWhat 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 HKWorkout with 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

  1. Project → Signing & Capabilities → + Capability → HealthKit.
  2. Tick Clinical Health Records only if you read FHIR records (extra App Review).
  3. 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 sleepAnalysis category samples.
  • MyFitnessPal — writes dietaryEnergyConsumed, reads bodyMass.
  • Apollo Neuro, Calm — write mindfulSession category samples for meditation tracking.

Common misconceptions

  1. requestAuthorization returns 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.
  2. “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.
  3. “HealthKit works on iPad.” Limited support landed in iPadOS 17. Older iPads cannot host HealthKit; sample apps should guard with HKHealthStore.isHealthDataAvailable().
  4. “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.
  5. “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 withCheckedThrowingContinuation once; 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 HKStatisticsCollectionQuery with .cumulativeSum and a daily interval anchor to compute weekly/monthly summaries server-style on-device. It’s an order of magnitude faster than HKSampleQuery + 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), run HKStatisticsQuery with a predicate from startOfDay to now, options .cumulativeSum. Read stats.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 an HKObserverQuery with completion handler; the OS wakes the app and calls it when new samples land. In the handler, run a follow-up HKSampleQuery from 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 via HKAnchoredObjectQuery for incremental sync, statistics-collection queries for chart data. On wakeup from observer, batch-process new samples and write a correlated mindfulSession if 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 .notDetermined even 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