HealthKit in SwiftUI: The Complete Guide to Reading, Writing, and Live Health Data in iOS 26
Ship real HealthKit features in SwiftUI on iOS 26: async authorization, hourly step buckets, live heart rate as an AsyncSequence, workout builders, and background delivery, with all the Info.plist gotchas laid out.
HealthKit in SwiftUI lets you read and write body, fitness, and clinical data from the shared Health database using an HKHealthStore, request per-type authorization, and stream live samples with async queries. In iOS 26, Apple modernized the framework with async/await variants for every query, native AsyncSequence observers, and a new @Observable-friendly session model, so you can finally build reactive health UIs without wrapping delegate-style APIs by hand. I've been shipping HealthKit code since iOS 8, and honestly, this is the first release where the SwiftUI story feels first-class. This guide walks you through authorization, reading, writing, background delivery, and privacy end to end.
iOS 26 adds async/await overloads to HKHealthStore for every sample, statistics, and workout query, so completion handlers are optional now.
Authorization is per-type and per-direction. Call requestAuthorization(toShare:read:) before any read or write, and check status with statusForAuthorizationRequest.
Use HKStatisticsCollectionQuery for hour/day buckets like step count. Use HKAnchoredObjectQuery or the new samples(for:) AsyncSequence for live updates.
HealthKit doesn't run in the iOS Simulator for most types. Test on a physical iPhone or Apple Watch, or seed data via the Health app.
Add both NSHealthShareUsageDescription and NSHealthUpdateUsageDescription to Info.plist and enable the HealthKit capability, or the app will crash on first access.
Background delivery needs HKObserverQuery plus enableBackgroundDelivery(for:frequency:) and the HealthKit background modes entitlement.
What is HealthKit and what's new in iOS 26
HealthKit is Apple's cross-device data store for health, fitness, and clinical records, backed by a single encrypted database on the iPhone that syncs to Apple Watch and, with user consent, to third-party apps. Every value lives under a strongly typed identifier (like HKQuantityTypeIdentifier.stepCount, .heartRate, or .activeEnergyBurned, and hundreds more), and every read or write is gated by an explicit user grant that the app never sees directly.
So, what's actually new? iOS 26 makes three concrete additions worth knowing before you start. First, every HKHealthStore query now has an async throws overload. The completion-handler forms are still available, but the async versions integrate cleanly with SwiftUI's .task and @Observable. Second, the new samples(for:) method on HKHealthStore returns an AsyncSequence of anchored updates, so you can drive a view with a plain for await loop instead of anchor bookkeeping. Third, the workout builder now accepts a Swift concurrency DiscardingTaskGroup for concurrent sample writes, which materially improves throughput for interval workouts.
The rest of the surface (types, units, quantities, statistics collections) is unchanged. So the patterns in this guide work back to iOS 17 with the classic completion APIs.
How do you request HealthKit authorization in SwiftUI?
Authorization in HealthKit is per-type and per-direction, meaning read access to step count is a separate grant from write access to step count. Build two sets, one Set<HKSampleType> for read and one for write, pass them to requestAuthorization(toShare:read:), and inspect the outcome. Here's the part that trips people up on their first HealthKit app: HealthKit deliberately does not tell you whether the user granted read access. It only tells you the request completed. That's a privacy feature, not a bug. You learn read status implicitly by whether queries return data.
Below is a complete @Observable service you can inject into a SwiftUI view. It centralizes the store, the type sets, and an isAuthorized flag your views can bind to. Because HealthKit's async APIs throw, I surface a single error property for UI display.
import HealthKit
import Observation
@Observable
final class HealthService {
let store = HKHealthStore()
// What we read
let readTypes: Set<HKSampleType> = [
HKQuantityType(.stepCount),
HKQuantityType(.heartRate),
HKQuantityType(.activeEnergyBurned),
HKQuantityType(.dietaryWater),
HKWorkoutType.workoutType()
]
// What we write
let writeTypes: Set<HKSampleType> = [
HKQuantityType(.dietaryWater),
HKWorkoutType.workoutType()
]
var isAuthorized = false
var error: Error?
func requestAuthorization() async {
guard HKHealthStore.isHealthDataAvailable() else {
error = HealthError.unavailable
return
}
do {
try await store.requestAuthorization(toShare: writeTypes, read: readTypes)
isAuthorized = true
} catch {
self.error = error
}
}
enum HealthError: Error { case unavailable }
}
Wire it up in a view with .task so the prompt appears on first appearance. If the async patterns feel familiar, that's because they mirror the shape we covered in Swift TaskGroup and AsyncStream. HealthKit is now a first-class citizen of structured concurrency.
struct RootView: View {
@State private var health = HealthService()
var body: some View {
DashboardView()
.environment(health)
.task { await health.requestAuthorization() }
}
}
How do you read step count in SwiftUI with HealthKit?
Step count is a cumulative quantity, so you almost never want the raw samples. You want a bucketed sum. HKStatisticsCollectionQuery aggregates samples by an anchor date and an interval (typically one hour or one day) and hands you back an HKStatisticsCollection. In iOS 26 you can drop the delegate-style handler entirely and use statisticsCollection(for:with:anchorDate:intervalComponents:) as an async call.
The example below reads today's step count in hourly buckets and returns an array of (Date, Int) you can hand straight to Swift Charts. Notice the anchor date is midnight. HKStatisticsCollectionQuery aligns every bucket to that anchor, so if you want your buckets to start at midnight local time, that's the anchor you pass.
extension HealthService {
struct HourlySteps: Identifiable {
let id = UUID()
let hour: Date
let steps: Int
}
func hourlyStepsToday() async throws -> [HourlySteps] {
let type = HKQuantityType(.stepCount)
let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: .now)
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: .now)
let collection = try await store.statisticsCollection(
for: type,
with: .cumulativeSum,
anchorDate: startOfDay,
intervalComponents: DateComponents(hour: 1),
predicate: predicate
)
var results: [HourlySteps] = []
collection.enumerateStatistics(from: startOfDay, to: .now) { stats, _ in
let count = stats.sumQuantity()?.doubleValue(for: .count()) ?? 0
results.append(HourlySteps(hour: stats.startDate, steps: Int(count)))
}
return results
}
}
Bind it in the view with a simple .task. For a running total instead of buckets, swap statisticsCollection for statistics(for:with:predicate:) and read result.sumQuantity() directly. One quick reminder: cumulative sample types (steps, distance, calories) support .cumulativeSum. Discrete types (heart rate, body mass) use .discreteAverage, .discreteMin, or .discreteMax. I've mixed these up more than once and gotten silently empty results.
Reading live heart rate with HKAnchoredObjectQuery
Heart rate is a discrete quantity that arrives from Apple Watch in near real time. To drive a live chart, you want a query that fires on every new sample without you re-running it. That's HKAnchoredObjectQuery. It returns the current batch plus an anchor, and when you install an updateHandler, subsequent samples arrive as they land in the store. In iOS 26 you can skip the anchor plumbing entirely with the new samples(for:) AsyncSequence.
extension HealthService {
/// iOS 26+ live heart rate stream as an AsyncSequence.
func liveHeartRate() -> AsyncThrowingStream<Double, Error> {
AsyncThrowingStream { continuation in
let type = HKQuantityType(.heartRate)
let task = Task {
do {
for try await update in store.samples(for: type) {
for sample in update.addedSamples {
guard let q = sample as? HKQuantitySample else { continue }
let bpm = q.quantity.doubleValue(
for: HKUnit.count().unitDivided(by: .minute())
)
continuation.yield(bpm)
}
}
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
}
Consume it from SwiftUI with a plain .task. Because it's a stream, the view stays reactive without any manual publisher wiring, which is a cleaner pattern than the Combine-based samples in older tutorials.
struct HeartRateView: View {
@Environment(HealthService.self) private var health
@State private var bpm: Double = 0
var body: some View {
Text("\(Int(bpm)) BPM")
.font(.system(size: 64, weight: .bold, design: .rounded))
.task {
do {
for try await value in health.liveHeartRate() {
bpm = value
}
} catch {
// Surface error
}
}
}
}
If you're targeting iOS 17 through 25, use the classic HKAnchoredObjectQuery with an updateHandler closure and wrap it in your own AsyncStream. The consumer-side ergonomics stay identical. See the HKAnchoredObjectQuery reference for the pre-iOS-26 API.
Writing samples and workouts to HealthKit
Writing a discrete sample is a three-step recipe. Build an HKQuantity with the right unit, wrap it in an HKQuantitySample with a start and end date, and hand it to store.save(_:). Water intake is a good example because the unit (fluid ounces or milliliters) is user-visible and easy to get wrong. I once shipped a water-tracking beta that logged everything in liters instead of milliliters. My testers were, apparently, drinking bathtubs.
extension HealthService {
func logWater(milliliters: Double, at date: Date = .now) async throws {
let type = HKQuantityType(.dietaryWater)
let quantity = HKQuantity(unit: .literUnit(with: .milli), doubleValue: milliliters)
let sample = HKQuantitySample(
type: type,
quantity: quantity,
start: date,
end: date
)
try await store.save(sample)
}
}
Workouts are richer. In iOS 26 you build them with HKWorkoutBuilder, adding metadata, samples, and events, then finish with a summary. The builder is thread-safe and works well with a task group when you're seeding many samples. Below is a minimal running workout with distance and active energy.
If you want the workout to also drive a live session on Apple Watch, pair the builder with an HKWorkoutSession. That's a watchOS-only API and it requires the Workout Processing background mode. The Workouts and Activity Rings documentation covers the session lifecycle in detail.
Background delivery with HKObserverQuery
By default, HealthKit reads only fire while your app is running. To be woken when new samples land (say, to update a Live Activity or send a notification when the user closes their rings), you need three things: an HKObserverQuery, a call to enableBackgroundDelivery(for:frequency:withCompletion:), and the HealthKit background modes entitlement.
extension HealthService {
func startBackgroundDelivery(for type: HKQuantityType) async throws {
try await store.enableBackgroundDelivery(for: type, frequency: .immediate)
let query = HKObserverQuery(sampleType: type, predicate: nil) { _, completion, error in
defer { completion() }
guard error == nil else { return }
Task { await self.handleUpdate(for: type) }
}
store.execute(query)
}
private func handleUpdate(for type: HKQuantityType) async {
// Fetch latest samples, update your model or post a notification
}
}
The frequency parameter is a hint, not a guarantee. .immediate is honored for heart rate and blood glucose. Other types are throttled to .hourly minimum regardless of what you pass. And always call the completion handler. If you don't, HealthKit stops delivering updates to your process, and you'll spend an afternoon wondering why (I hit this exact bug shipping a Live Activity last winter). For scheduling non-Health background work, our SwiftUI background tasks guide covers the BGTaskScheduler APIs you'll pair with this.
Privacy, entitlements, and Info.plist keys
HealthKit refuses to launch without three explicit pieces of configuration. Miss any one and the app either crashes at first store access or the authorization sheet never appears. Configure them in this order.
Enable the HealthKit capability in Xcode → Signing & Capabilities. This adds the entitlement and, if needed, provisions your bundle ID.
Add the two Info.plist keys. NSHealthShareUsageDescription is shown when you request read access. NSHealthUpdateUsageDescription is shown for write access. Both strings should describe the concrete health data you access and why. Vague strings like "Access your health" are a common App Review rejection.
Enable "Background delivery" under the HealthKit capability if you use enableBackgroundDelivery. Without it, iOS silently ignores your background delivery calls.
For clinical records (allergies, lab results, procedures), you need a fourth key: NSHealthClinicalHealthRecordsShareUsageDescription. Those records are also gated behind the user pairing a supported healthcare provider in the Health app, so simulator testing is impossible.
Testing HealthKit in the simulator versus on device
Most HealthKit types simply don't exist in the iOS Simulator. HKHealthStore.isHealthDataAvailable() returns true, authorization sheets appear, and queries succeed, but they return empty results for anything the simulator doesn't synthesize. Only a small set (step count, distance walking/running, active energy) is faked, and even those don't populate reliably. Plan for a physical device early.
Three practical testing tactics keep me productive:
Seed data via the Health app on a real iPhone. Open Health → Browse → Steps → Add Data, and you can inject any sample by hand. This is the fastest way to test read paths.
Use dependency injection. Wrap HKHealthStore behind a protocol so your unit tests can pass a fake. Because HealthKit reads are now async, this is straightforward. See the Swift Testing guide for the async patterns.
Test on Apple Watch for heart rate and workouts. Heart rate samples originate on the watch, and workout sessions only run there. Xcode's Devices & Simulators window lets you deploy WatchKit extensions directly.
For a broader look at Apple's official HealthKit documentation, start with the "Setting up HealthKit" and "Reading and writing samples" articles. The async overloads land alongside the classic examples throughout the reference.
Frequently Asked Questions
Does HealthKit work in the iOS Simulator?
Partially. The framework loads and authorization sheets appear, but most sample types return empty results because the simulator doesn't synthesize the underlying sensor data. Step count, walking distance, and active energy are the only types with limited fake data. Test on a real iPhone or Apple Watch for anything else.
Why does HealthKit not tell me if the user denied read access?
By design. Apple treats read denial as sensitive information, since knowing a user hid their heart rate could itself reveal a health condition. authorizationStatus(for:) returns .notDetermined or .sharingAuthorized for read types even after denial. You infer denial only by queries returning no data.
What is the difference between HKSampleQuery and HKAnchoredObjectQuery?
HKSampleQuery is a one-shot fetch: it returns whatever matches your predicate right now. HKAnchoredObjectQuery returns the current match plus an opaque anchor, and with an updateHandler installed it continues to deliver new and deleted samples as they arrive. Use anchored queries for live UIs and sync operations.
Can HealthKit read data from third-party apps like Strava?
Yes, if the user has granted that third-party app permission to write to Health. HealthKit is source-agnostic, so your read query returns samples regardless of which app wrote them. You can inspect the source with sample.sourceRevision.source if you need to filter or attribute values.
How often does HealthKit background delivery actually fire?
For most quantity types, hourly is the effective minimum regardless of the frequency you pass to enableBackgroundDelivery. Heart rate, blood glucose, and a handful of high-priority types honor .immediate. iOS also coalesces deliveries to save battery, so treat the timing as best-effort rather than real-time.
A practical guide to Apple's unified logging in Swift for iOS 26: how Logger replaces print, when to use each log level, how privacy annotations protect PII, and how OSLogStore and OSSignposter power in-app diagnostics and performance tracing.
SwiftUI's PhaseAnimator walks a view through ordered animation phases from a single trigger, replacing @State flags and asyncAfter chains. Learn per-phase curves, trigger patterns, and how to combine it with CustomAnimation, plus real-world code for like buttons and toasts.
Profile SwiftUI apps with Xcode Instruments in Xcode 26. A real-device workflow for finding hangs, frame drops, and memory leaks, plus custom signposts with OSSignposter.