HealthKit Architecture for Production iOS Apps: Data Models, Permissions, and Privacy
A production HealthKit architecture guide covering HKSample data modeling, progressive authorization, query selection, background delivery, privacy boundaries, SwiftUI isolation, and App Store compliance.
HealthKit sits at the intersection of two hard problems: a permission model users actively distrust, and a data architecture that behaves nothing like a standard database. Most HealthKit integrations fail not because the API is obscure, but because developers treat it like a persistence layer. It is not. It is a typed, unit-aware, user-controlled data store with its own query semantics.
This article covers the structural decisions that matter for production HealthKit apps: how the data model works, how to request permissions without triggering denial, how to query efficiently, and where the privacy boundaries actually sit.
The HealthKit Data Model Is Not a Database
HKHealthStore is not a database you own. It is a shared, sandboxed store managed by the OS. Your app reads from and writes to it with explicit per-type authorization. The user controls which types you can access and can revoke that access at any time — without your app receiving a notification.
Every piece of data in HealthKit is an HKSample. Samples subdivide into three concrete types:
HKQuantitySample— a numeric value with a unit, e.g.,HKQuantityTypeIdentifier.heartRateincount/minHKCategorySample— an enumerated value, e.g., sleep analysis stagesHKCorrelation— a compound sample grouping related quantities, used for blood pressure and food
Each sample carries a startDate, endDate, device, and sourceRevision. The sourceRevision identifies which app or device wrote the sample. This matters for conflict resolution when multiple sources write the same type.
HKWorkout is a special subclass of HKSample that groups samples under a single activity session. If your app records workouts, you write an HKWorkout and associate child samples via HKWorkoutBuilder.
Unit and Quantity Type Pairing
Every HKQuantitySample requires a compatible HKUnit. The store enforces this at write time. Saving a step count sample with a mass unit throws at runtime, not at compile time. Define your unit-type pairs once, centrally, and reference them by name.
extension HKQuantityType {
static let stepCount = HKQuantityType(.stepCount)
static let heartRate = HKQuantityType(.heartRate)
}
extension HKUnit {
static let stepsPerDay = HKUnit.count()
static let beatsPerMinute = HKUnit(from: "count/min")
}
This is not boilerplate. It is the boundary between a type-safe HealthKit layer and one that crashes in production.
Permission Architecture
The Constraint That Shaped Everything
HealthKit permission is not a binary toggle. Each HKObjectType requires separate read and write authorization. The user grants or denies each type independently. Your app cannot determine whether a specific type was denied — HKHealthStore returns .notDetermined for denied types to prevent apps from fingerprinting what health data a user has.
This asymmetry is the design premise. Every architectural decision flows from that.
Requesting Authorization Correctly
The obvious approach: request all types your app might ever need at first launch. The problem: users see a long permission sheet, assume the app is overreaching, and deny everything.
The correct approach is progressive authorization — request only the types required for the current user action, at the moment that action is initiated.
func requestHeartRateAuthorization() async throws {
let typesToRead: Set<HKObjectType> = [HKQuantityType.heartRate]
try await healthStore.requestAuthorization(toShare: [], read: typesToRead)
}
Call requestAuthorization(toShare:read:) immediately before the first query that needs the type. If authorization was already granted, the call returns immediately with no UI. If it was denied, it also returns immediately — and your app must handle the .notDetermined response gracefully, not by crashing or blocking the UI.
Handling the Authorization State
authorizationStatus(for:) returns one of three values: .notDetermined, .sharingAuthorized, or .sharingDenied. For read access, the API always returns .notDetermined regardless of actual status. This is intentional.
The practical consequence: never gate UI on confirmed read authorization. Execute the query and handle an empty result set. An empty result is indistinguishable from a denied permission at the API level. Design your UI to handle both cases identically.
Query Architecture
Choosing the Right Query Type
HealthKit provides several query classes. Using the wrong one produces either stale data or unnecessary battery drain.
HKSampleQuery— one-shot fetch, returns a snapshot. Use for historical data display.HKStatisticsQuery— aggregates (sum, average, min, max) over a time range. Use for step totals, average heart rate, etc.HKStatisticsCollectionQuery— aggregates bucketed into time intervals. Use for charts and trend analysis.HKObserverQuery— background observer that fires when new samples matching a predicate arrive. Use for live dashboards.HKAnchoredObjectQuery— returns new and deleted samples since a stored anchor. Use for incremental sync.
HKAnchoredObjectQuery is the correct choice for any feature that needs to stay current with HealthKit data without polling. It returns an HKQueryAnchor on each execution. Persist that anchor. On the next execution, pass it back — the query returns only what changed.
var anchor: HKQueryAnchor? = loadPersistedAnchor()
let query = HKAnchoredObjectQuery(
type: HKQuantityType.stepCount,
predicate: nil,
anchor: anchor,
limit: HKObjectQueryNoLimit
) { _, samples, deletedObjects, newAnchor, error in
anchor = newAnchor
persistAnchor(newAnchor)
processSamples(samples)
}
healthStore.execute(query)
Polling with repeated HKSampleQuery calls is the wrong pattern. It misses deletions, wastes CPU cycles, and produces stale reads between polls.
Background Delivery
HKObserverQuery combined with enableBackgroundDelivery(for:frequency:withCompletion:) allows HealthKit to wake your app when new data arrives, even when the app is not running. The frequency parameter accepts .immediate, .hourly, .daily, or .weekly. Apple throttles delivery based on type — heart rate can deliver immediately, while step counts may batch.
Background delivery requires the HealthKit background delivery entitlement and the UIBackgroundModes key in your Info.plist. The OS wakes your app, calls your observer query's update handler, and expects the provided completion handler to be called within a short window. Failing to call it terminates background delivery for that type.
Privacy Architecture
What Stays on Device
HealthKit data never leaves the device unless your app explicitly reads it and sends it somewhere. The store itself is local. CloudKit sync for HealthKit data goes through the user's private iCloud container — not yours. Your app has no access to that sync path.
The implication: if your app reads HealthKit data and transmits it to your server, you own that data pipeline and its compliance obligations. HealthKit being involved does not confer privacy by default.
Minimizing Read Scope
Request the minimum set of types required for each feature. If your app shows a step count widget, request only HKQuantityTypeIdentifier.stepCount. Do not request heartRate, bloodPressureSystolic, or sleepAnalysis unless a specific feature requires them.
This is not just a privacy best practice. App Store review scrutinizes HealthKit permission requests. Requesting types without a clear in-app use case is a rejection reason.
On-Device Processing as the Default
If your app performs any analysis on health data — trend detection, anomaly flagging, recommendations — that analysis belongs on device. Sending raw HealthKit samples to a server for processing introduces a data handling obligation that on-device inference eliminates entirely.
Privacy-preserving AI architectures cover this in detail: Core ML inference runs against local data, produces a result, and the raw input never leaves the device. For health data specifically, this is not an architectural preference — it is the only defensible design.
Data Layer Integration
HealthKit as a Source, Not a Store
The correct mental model: HealthKit is a data source your app reads from, not a store your app owns. Your app's persistence layer — Core Data, SwiftData, or a local SQLite database — holds the derived state your app needs. HealthKit holds the raw samples.
This separation matters for two reasons. HealthKit queries are asynchronous and relatively expensive — querying on every view render is the wrong pattern. Your app's data model also likely needs to associate health samples with domain objects (a workout session, a user goal, a logged meal) that HealthKit has no concept of.
The correct pattern: query HealthKit once (or via observer), transform the samples into your domain model, persist the domain model locally, and render from the local store.
Conflict Resolution for Write-Capable Apps
If your app writes samples back to HealthKit, multiple sources may write the same type. A user who wears an Apple Watch and uses your app to log workouts will have overlapping step count samples from both sources.
HKStatisticsCollectionQuery handles this via a mergePolicy. The two relevant policies are .discreteAverage and .cumulativeSum. Step counts use .cumulativeSum — but HealthKit only deduplicates overlapping samples from the same device automatically. Cross-device deduplication is not automatic. Query results may double-count if you sum across all sources without filtering by sourceRevision.
Filter by source when the query is source-specific:
let sourcePredicate = HKQuery.predicateForObjects(from: [HKSource.default()])
let query = HKSampleQuery(
sampleType: HKQuantityType.stepCount,
predicate: sourcePredicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: nil
) { _, samples, error in
// samples contain only this app's writes
}
SwiftUI Integration
Isolating HealthKit from the View Layer
HealthKit queries are not synchronous. They complete on arbitrary background queues. The view layer must never call HKHealthStore directly.
All HealthKit interaction belongs inside an actor-isolated service type. The service publishes derived state via @Published properties or AsyncStream<String>. The SwiftUI view observes that published state and renders from it.
@MainActor
final class HealthDataStore: ObservableObject {
@Published private(set) var dailySteps: Int = 0
private let healthStore = HKHealthStore()
func loadSteps(for date: Date) async {
// query executes, result published on MainActor
}
}
The view renders whatever dailySteps holds. The store updates it when the query completes. No direct HealthKit calls in view body or onAppear.
For a broader treatment of SwiftUI data flow patterns, the SwiftUI architecture guide covers the layering decisions that apply here.
App Store Compliance
The HealthKit Entitlement
HealthKit requires the com.apple.developer.healthkit entitlement. Enable it in your target's Capabilities tab in Xcode — this modifies both the entitlements file and the App ID in the developer portal.
Missing this entitlement produces a runtime crash, not a compile-time error. The crash message is not always obvious. Add this to your pre-submission checklist.
Required Info.plist Keys
Two keys are mandatory:
NSHealthShareUsageDescription— explains why the app reads health dataNSHealthUpdateUsageDescription— required if the app writes health data
Both strings appear in the system permission sheet. Write them as specific descriptions of what data is accessed and why. Vague strings like "to improve your experience" are a review rejection risk.
The AI-native iOS app architecture checklist includes HealthKit entitlement and Info.plist checks as part of the App Store compliance section — useful if your app combines health data with on-device AI features.
Background Delivery Entitlement
Background delivery requires a separate entitlement: com.apple.developer.healthkit.background-delivery. This is distinct from the base HealthKit entitlement. Apps submitted without it that call enableBackgroundDelivery will fail silently in production.
Testing HealthKit-Dependent Code
The Simulator Limitation
The iOS Simulator does not populate HealthKit with real data. You can write test samples programmatically, but the store starts empty. Any test that depends on HealthKit returning non-empty results must either write sample data as part of test setup or mock the HKHealthStore interface.
The correct approach for unit testing: define a protocol that HKHealthStore conforms to via an extension, and inject a mock conformance in tests. This makes query logic testable without a physical device.
Physical Device Testing
Authorization flows require a physical device. The permission sheet does not appear in the Simulator. End-to-end testing of the authorization request, denial handling, and background delivery must happen on device.
Build a dedicated test target or use TestFlight for authorization flow validation. Do not rely on the Simulator for any HealthKit permission path.
Production Considerations
Apps that read sensitive health data carry implicit user trust. The architectural decisions that protect that trust — on-device processing, minimal permission scope, zero server transmission of raw samples — are not optional for apps that expect users to actually grant access.
The pattern 3Nsofts uses for health-data apps: HealthKit as a read source, Core Data as the local derived store, Core ML for any inference that touches health samples, and zero cloud exposure of raw samples. This is the same architecture described in the CalmLedger case study, where financial data follows an identical local-first, on-device-inference pattern.
If you are building a production HealthKit app and need architecture review before shipping, the audit service at 3nsofts.com surfaces permission, compliance, and data-flow issues that cause App Store rejections and user trust failures before they reach production.
FAQs
Q: Can my app detect whether a user denied HealthKit read access?
A: No. authorizationStatus(for:) returns .notDetermined for denied read types. Apple prevents apps from inferring what health data a user has by observing denial patterns. Design your app to treat empty query results and denied access identically.
Q: What is the correct query type for a live heart rate display?
A: HKAnchoredObjectQuery with an active HKObserverQuery for background delivery. The observer fires when new heart rate samples arrive. The anchored query fetches only samples added since the last anchor. Polling with HKSampleQuery on a timer is the wrong pattern — it misses deletions and wastes CPU.
Q: Does HealthKit data sync across a user's devices automatically?
A: HealthKit syncs through the user's private iCloud container, not your app's container. Your app has no visibility into or control over that sync path. If your app's derived data needs to sync across devices, that is a separate sync architecture your app owns — typically Core Data with NSPersistentCloudKitContainer.
Q: What happens if a user revokes HealthKit permission while my app is running? A: The store returns empty results on subsequent queries. Your app receives no notification of the revocation. Observer queries stop firing. Handle empty results gracefully at all times — the revocation case and the no-data case are architecturally identical.
Q: How should I handle HealthKit in a SwiftUI app without creating threading issues?
A: Isolate all HKHealthStore interaction inside a @MainActor-annotated ObservableObject or a Swift actor. Never call HealthKit APIs from a view body or onAppear. Publish derived state via @Published properties and let SwiftUI observe those. This keeps the view layer deterministic and prevents data races on query completion handlers.
Q: Is the NSHealthShareUsageDescription key required even if my app only writes data?
A: Yes, if your app requests read authorization for any type, even implicitly. In practice, most apps that write health data also read it for display. Include both NSHealthShareUsageDescription and NSHealthUpdateUsageDescription if your app touches HealthKit in any direction.
Q: What is the minimum viable HealthKit permission request for an App Store submission?
A: Request only the specific HKObjectType identifiers your app uses in production. Each type must map to a visible in-app feature. App Store review rejects requests for types without a clear use case. The Info.plist usage description strings must name the specific data types and the specific reason — not a generic privacy statement.
Work With Me
The iOS Architecture Audit reviews data-layer structure, sync strategy, App Store compliance, and on-device AI readiness, then delivers a written recommendations report in 5 business days.
Related
- HealthKit Integration Guide for iOS Health Apps
- HealthKit Integration Architecture for iOS Dashboards
- Privacy-First App Architecture