Weather.Co is now free for every pilot — always free for CFIs and flight schools.
Lab · Guides · HealthKit deep-read
Lab guide · HealthKit

HealthKit deep-read.

Reading, writing, and observing — 8 write types, background delivery, and the partial-authorization quirk that catches every team building a serious Health app the first time around.

HealthKit is iOS's health data store. It's the right primitive for any app that needs the user's heart rate, sleep, workouts, or 100+ other quantities — and the only way to write data that other apps (and Apple Health) will see. This guide covers the patterns I've used in tmpo (read + write + observe across 8 write types) and on Weather.Co's wrist surface.

Before you start
  • An iOS app with the HealthKit capability added in Signing & Capabilities.
  • NSHealthShareUsageDescription and NSHealthUpdateUsageDescription in your Info.plist — Apple Review rejects apps without both.
  • Familiarity with Swift concurrency (async/await) — the modern HealthKit API uses it throughout.
  • A real device for testing. The simulator has limited (mostly fake) Health data.

Step 1Request authorization

HealthKit auth is two-way: you ask for read permissions and write permissions separately. The 8 write types I've shipped in tmpo: body mass, body fat percentage, lean body mass, height, dietary energy, dietary protein, dietary carbohydrates, dietary fat. The reads are a much longer list — heart rate, HRV, sleep, workouts, etc.

import HealthKit

final class HealthStore {
  static let shared = HealthStore()
  let store = HKHealthStore()

  let readTypes: Set<HKObjectType> = [
    HKQuantityType(.heartRate),
    HKQuantityType(.heartRateVariabilitySDNN),
    HKQuantityType(.activeEnergyBurned),
    HKQuantityType(.stepCount),
    HKCategoryType(.sleepAnalysis),
    HKObjectType.workoutType(),
    // …add the rest you need
  ]
  let writeTypes: Set<HKSampleType> = [
    HKQuantityType(.bodyMass),
    HKQuantityType(.bodyFatPercentage),
    HKQuantityType(.leanBodyMass),
    HKQuantityType(.height),
    HKQuantityType(.dietaryEnergyConsumed),
    HKQuantityType(.dietaryProtein),
    HKQuantityType(.dietaryCarbohydrates),
    HKQuantityType(.dietaryFatTotal),
  ]

  func requestAuth() async throws {
    guard HKHealthStore.isHealthDataAvailable() else { throw HKError(.errorHealthDataUnavailable) }
    try await store.requestAuthorization(toShare: writeTypes, read: readTypes)
  }
}
The partial-authorization quirk

The user can decline any individual read or write type from the auth sheet. Crucially, your app cannot tell which read types they declined — Apple withholds that to prevent fingerprinting. Reading a declined quantity just returns an empty result, indistinguishable from "no data."

The right pattern: build for partial data. Show empty states gracefully. Don't display "you need to authorize X" UI based on the auth result (you can't trust it for reads). Re-request anytime; iOS handles "already prompted" silently.

Step 2Read a sample (the modern way)

func latestHeartRate() async throws -> Double? {
  let type = HKQuantityType(.heartRate)
  let descriptor = HKSampleQueryDescriptor(
    predicates: [.sample(type: type)],
    sortDescriptors: [SortDescriptor(\.endDate, order: .reverse)],
    limit: 1
  )
  let results = try await descriptor.result(for: store)
  guard let sample = results.first as? HKQuantitySample else { return nil }
  return sample.quantity.doubleValue(for: HKUnit(from: "count/min"))
}

Step 3Write a sample

func logBodyMass(_ kg: Double, at date: Date = .now) async throws {
  let type = HKQuantityType(.bodyMass)
  let qty = HKQuantity(unit: .gramUnit(with: .kilo), doubleValue: kg)
  let sample = HKQuantitySample(type: type, quantity: qty, start: date, end: date)
  try await store.save(sample)
}

Critical: pass start == end for a point-in-time reading, or start ≠ end for a duration (e.g. dietary intake over a meal). Apple Health uses this to lay the sample out on the timeline.

Step 4Observe changes

You don't poll HealthKit. You register an HKObserverQuery and HealthKit wakes your app when new data arrives — even when the app is in the background (with the right entitlement).

func startObserving() {
  let type = HKQuantityType(.stepCount)
  let query = HKObserverQuery(sampleType: type, predicate: nil) { [weak self] _, completion, error in
    defer { completion() }
    guard error == nil else { return }
    Task { await self?.refreshUI() }
  }
  store.execute(query)
  store.enableBackgroundDelivery(for: type, frequency: .hourly) { _, _ in }
}

enableBackgroundDelivery requires the HealthKit background delivery entitlement, which Apple grants on request (in App Store Connect). Without it, observers only fire when the app is foreground.

Step 5Aggregate (the right primitive for charts)

For dashboards — "steps per day for the last 30 days" — use HKStatisticsCollectionQueryDescriptor rather than fetching every sample and grouping yourself. HealthKit aggregates server-side and it's dramatically faster.

func dailySteps(days: Int) async throws -> [(Date, Double)] {
  let end = Calendar.current.startOfDay(for: .now).addingTimeInterval(86400)
  let start = Calendar.current.date(byAdding: .day, value: -days, to: end)!
  let type = HKQuantityType(.stepCount)
  let descriptor = HKStatisticsCollectionQueryDescriptor(
    predicate: HKSamplePredicate.quantitySample(type: type),
    options: .cumulativeSum,
    anchorDate: end,
    intervalComponents: .init(day: 1)
  )
  let stats = try await descriptor.result(for: store)
  var rows: [(Date, Double)] = []
  stats.enumerateStatistics(from: start, to: end) { stat, _ in
    let count = stat.sumQuantity()?.doubleValue(for: .count()) ?? 0
    rows.append((stat.startDate, count))
  }
  return rows
}
Tip — never crash on missing types

HKQuantityType(.heartRateVariabilitySDNN) exists on every modern iOS, but some types added in newer OS releases will be unavailable on older devices. Guard with #available or check HKHealthStore.isHealthDataAvailable() at app startup and bail to a "no Health" experience if false.

Common gotchas
  • Background delivery not firing. You don't have the HealthKit background delivery entitlement. Request it from App Store Connect → Certificates & Profiles.
  • Simulator returns no data. Expected. Test on a real device.
  • Write succeeds but Apple Health doesn't show the sample. Health UI is cached; force-quit the Health app, or wait ~30s.
  • App rejected in App Review with "missing Health usage strings" — both NSHealthShareUsageDescription and NSHealthUpdateUsageDescription are required even if you only read.
  • Auth sheet appears each launch. You're calling requestAuthorization every launch. It's idempotent — but only show it when you actually need it (e.g. before first read).

The summary: build for partial data (Apple won't tell you what's denied for reads), use the modern async API throughout, prefer aggregation queries for any UI that's "data over time," and remember background delivery needs an extra entitlement. tmpo's full Today surface — rings, decision, three rings, readiness — is built on this exact stack.

Sources: Apple Developer — HealthKit, HKSampleQueryDescriptor, HKStatisticsCollectionQueryDescriptor; tmpo project memory (8 write types). Tested on iOS 17+ and iOS 18 betas.