Skip to main content
3Nsofts logo3Nsofts

Insights / iOS Architecture

HealthKit Integration in iOS Apps: Architecture Patterns for Health Data Dashboards

May 2026·12 min read·iOS Architecture

HealthKit gives you access to a rich stream of sensor data. What it does not give you is a clear architecture for turning that data into a reliable dashboard. The query types are powerful but low-level—understanding which one to use, when to cache results, and how to handle the concurrency model correctly separates apps that work from apps that drain batteries, show stale data, or crash on edge cases.

The structural problem with health data

Health data has a shape that most iOS data layers are not designed for. It is continuous, time-series, high-frequency, and written by sources your app does not control. Your app is a reader, not the owner.

This creates a specific problem for dashboards. A dashboard needs aggregated summaries—daily step totals, weekly average heart rate, sleep duration over 30 days. It does not need raw samples. Fetching raw samples and aggregating them in-memory is the wrong approach: it is slow, memory-intensive, and cannot update incrementally.

The right approach uses HealthKit’s own aggregation primitives. The architectural challenge is knowing which query type to reach for, how to schedule it, and where to put the results in your data model.

What HealthKit actually is

HealthKit is a centralized datastore managed by HKHealthStore. It persists samples—typed quantities or categories with timestamps—written by your app, by the system, and by any other health-enabled app the user has installed. Your app reads from this shared store; it does not own any of the data.

A few constraints shape every architectural decision:

  • HealthKit is iPhone-only. HKHealthStore.isHealthDataAvailable() returns false on iPad. Handle this at the feature level, not just at launch.
  • Authorization is per data type. You request read and write access separately for each HKObjectType.
  • Read authorization status is private. Due to Apple’s privacy model, your app cannot determine whether the user denied a read permission or simply has no data of that type. Both states look identical from your perspective. Design your UI to tolerate this ambiguity.
  • Queries run on background queues. The HealthKit API is not MainActor-aware. You are responsible for getting results back to the main thread.

Permission architecture

Requesting authorization

Batch all the types your app needs into a single authorization request. One system prompt is less intrusive than several, and the user can approve or deny them together.

let typesToRead: Set<HKObjectType> = [
    HKQuantityType(.stepCount),
    HKQuantityType(.heartRate),
    HKQuantityType(.activeEnergyBurned),
    HKCategoryType(.sleepAnalysis),
]

store.requestAuthorization(toShare: nil, read: typesToRead) { success, error in
    guard success else {
        // Authorization dialog was shown; 'success' means it was presented,
        // not that the user approved. Always check data availability separately.
        return
    }
}

The success parameter in the callback means the authorization sheet was presented successfully—not that the user approved your request. Never use success as a signal that data is available. Query the data and handle empty results.

Background delivery

If your dashboard should update when the user returns to the app after a walk or workout, register for background delivery using enableBackgroundDelivery. Pair it with an HKObserverQuery that wakes your app when new data arrives for a given type.

  • Use HKUpdateFrequency.immediate only if your app genuinely needs near-real-time updates. It has a non-trivial battery cost.
  • Register the observer query before calling enableBackgroundDelivery. The delivery will not fire without an active observer.
  • Background delivery does not work in the simulator. Test it on a physical device before shipping.
  • Add the com.apple.developer.healthkit.background-delivery entitlement if you use this feature.

Query architecture

Two query types cover most dashboard requirements. HKStatisticsCollectionQuery for aggregated time-series. HKAnchoredObjectQuery for incremental sync. Choose based on what the UI needs.

HKStatisticsCollectionQuery for dashboard data

This is the right query for time-bucketed aggregation: steps per day, calories per week, resting heart rate per month. It returns pre-computed statistics per interval so you do not have to aggregate raw samples yourself.

let calendar = Calendar.current
let anchorDate = calendar.startOfDay(for: Date())
let interval = DateComponents(day: 1)
let predicate = HKQuery.predicateForSamples(
    withStart: startDate,
    end: endDate,
    options: .strictStartDate
)

let query = HKStatisticsCollectionQuery(
    quantityType: HKQuantityType(.stepCount),
    quantitySamplePredicate: predicate,
    options: .cumulativeSum,
    anchorDate: anchorDate,
    intervalComponents: interval
)

query.initialResultsHandler = { _, results, error in
    guard let results else { return }
    results.enumerateStatistics(from: startDate, to: endDate) { stats, _ in
        let steps = stats.sumQuantity()?.doubleValue(for: .count()) ?? 0
        // map to your domain model here
    }
}

store.execute(query)

Set the anchor date to the start of the day in the user’s current calendar, not UTC midnight. A mismatch here produces off-by-one bucket boundaries that are hard to debug because the data looks almost right.

The .strictStartDate predicate option ensures that only samples whose start date falls within your range are included—important for workouts that span midnight.

HKAnchoredObjectQuery for incremental updates

When your dashboard needs to stay current as new samples arrive, use HKAnchoredObjectQuery with a long-running update handler. The anchor mechanism lets you request only the samples that were added or deleted since your last fetch, making incremental sync efficient.

  • Persist the anchor between app launches using UserDefaults or a similar mechanism. Without it, every launch triggers a full refetch.
  • The update handler fires on a background queue. Same concurrency considerations as all other HealthKit queries apply.
  • Use this query type for recently-written samples (e.g., a workout just completed). For historical aggregation, prefer HKStatisticsCollectionQuery.

Data layer design

Separating HealthKit from your domain model

Do not let HKStatistics or HKSample propagate into your view models or UI. Map HealthKit types to domain structs at the data layer boundary. This makes testing trivial, makes the HealthKit dependency explicit, and protects you from HealthKit API changes.

struct DailyStepSummary {
    let date: Date
    let stepCount: Int
    let goalPercentage: Double
}

extension HKStatistics {
    func asDailyStepSummary(goal: Int) -> DailyStepSummary {
        let steps = sumQuantity()?.doubleValue(for: .count()) ?? 0
        return DailyStepSummary(
            date: startDate,
            stepCount: Int(steps),
            goalPercentage: steps / Double(goal)
        )
    }
}

Keep the mapping in an extension on the HealthKit type, not in your domain model. The domain model should not import HealthKit.

Caching and local persistence

HealthKit queries are not instantaneous. For a dashboard that shows data immediately on launch, cache your computed summaries locally.

  • For simple cases, serialize domain structs to UserDefaults or a JSON file in the app container. Fast to implement, adequate for small payloads.
  • For longer histories or richer queries against cached data, use Core Data. Persist your domain structs as managed objects. Re-fetch from HealthKit in the background and update the cache. This pairs well with local-first architecture patterns.
  • Always show cached data immediately, then refresh in the background. A dashboard that shows a spinner before any data appears is a worse experience than stale data with a subtle refresh indicator.
  • Invalidate cache entries when the user revokes HealthKit permissions. A deleted sample in HealthKit does not automatically remove your cached copy.

Concurrency patterns

HealthKit callbacks run on an arbitrary background queue. They are not Swift concurrency-aware. You cannot call await inside them, and you cannot mutate @Published or @Observable properties from them directly.

The correct pattern: do your transformation in the callback, then hop to @MainActor using Task { @MainActor in }:

// HealthKit query callbacks run on a background queue.
// Capture self weakly and hop to MainActor explicitly.

query.initialResultsHandler = { [weak self] _, results, error in
    guard let self, let results else { return }

    var summaries: [DailyStepSummary] = []
    results.enumerateStatistics(from: startDate, to: endDate) { stats, _ in
        summaries.append(stats.asDailyStepSummary(goal: self.dailyGoal))
    }

    Task { @MainActor in
        self.stepSummaries = summaries
    }
}

The [weak self] capture is essential. HealthKit queries can outlive the object that created them, especially with long-running observer queries. A strong capture here is a memory leak.

If you are adopting Swift 6 strict concurrency, isolate your HealthKit service to a dedicated actor. See Swift 6 actor isolation patterns for the same pattern applied to AI workloads—the HealthKit case is structurally identical.

Common failure modes

These are the issues that appear in production after passing development testing:

Wrong time zone in anchor date

Using UTC midnight as the anchor date instead of Calendar.current.startOfDay(for:) shifts all interval boundaries by the user’s UTC offset. Step counts appear off by one day for users outside UTC. Passes all development tests because developers often test in their own timezone.

Treating authorization success as data availability

The success flag in the authorization callback means the sheet was shown, not that the user approved. Apps that gate data fetching on this flag will silently skip all HealthKit queries after a denial.

Forgetting iPad availability check

HealthKit is unavailable on iPad. Apps that call HKHealthStore APIs without first checking isHealthDataAvailable() will throw at runtime on iPad.

Strong self capture in query callbacks

Long-running observer queries hold a strong reference to their callback closure. A strong self capture in that closure prevents deallocation of view models and controllers that have been dismissed. Always use [weak self] in HealthKit query closures.

Main thread mutation from HealthKit callback

HealthKit callbacks run on a background queue. Updating UI-bound state properties directly from the callback causes runtime warnings in SwiftUI (and crashes in some configurations). Always dispatch back to the main actor before mutating observable properties.

No local cache, cold start shows spinner

HealthKit queries take 100–500 ms on first launch, depending on the date range and data volume. Apps without a local cache show a loading state every time the dashboard opens. Cache the last computed summaries and display them immediately while the background refresh runs.

Frequently asked questions

What is the minimum iOS version required for HealthKit integration?

HealthKit is available on iOS 8.0 and later. The HKStatisticsCollectionQuery API has been available since iOS 8. Swift concurrency patterns (async/await, actors) require iOS 15+, which is a reasonable deployment target for any health app started today.

Can HealthKit be used on iPad?

No. HKHealthStore.isHealthDataAvailable() returns false on iPad. Handle this at the feature level — disable the health features and display an explanatory message rather than showing empty states or crashing.

How do I know if the user denied HealthKit read permission?

You cannot, by design. Apple hides the distinction between 'user denied' and 'not yet requested' for read permissions. HKAuthorizationStatus will always appear as notDetermined from your app's perspective, even after denial. Design your UI to tolerate this ambiguity — queries that return no data should show an appropriate empty state, not an error.

What is the difference between HKStatisticsCollectionQuery and HKSampleQuery for dashboard use?

HKSampleQuery returns raw samples — individual data points with timestamps. For a dashboard showing daily step totals or weekly averages, you would aggregate those samples yourself. HKStatisticsCollectionQuery does that aggregation inside HealthKit, returning pre-computed statistics per time interval. For any dashboard use case, HKStatisticsCollectionQuery is almost always the right choice.

How should HealthKit data be tested in unit tests?

Abstract HealthKit behind a protocol. Your HealthKitService protocol exposes methods like fetchWeeklySteps(from:to:) returning domain types. In tests, inject a mock that returns pre-built data without touching HKHealthStore. This lets you test the transformation and caching logic independently of the HealthKit data layer.

Does HealthKit background delivery work in the simulator?

No. Background delivery requires a real device. In the simulator, enableBackgroundDelivery will succeed without error, but the observer query will never fire. Test background delivery on physical hardware before shipping.

What entitlements are required for HealthKit integration?

You need the HealthKit entitlement (com.apple.developer.healthkit) in your Entitlements file. If your app uses background delivery, also add com.apple.developer.healthkit.background-delivery. Both must be configured in your App ID in the Apple Developer portal and included in your provisioning profile.

Related

Building a health feature? Let’s review the architecture.

HealthKit integration is straightforward to get working and surprisingly easy to get wrong at scale. A codebase audit catches the failure modes before they reach production.