HealthKit: Workouts, Mental Health APIs, and iPhone-Native Sessions


Health and fitness apps consistently rank among the top App Store categories, yet HealthKit remains one of the most underused frameworks among iOS developers. The authorization flow is intimidating, the query API has a steep learning curve, and writing workout data correctly requires understanding workout builders, activity types, and sample associations. On top of that, iOS 18 added mental health tracking with HKStateOfMind, and iOS 26 introduced iPhone-native workout sessions — eliminating the Apple Watch requirement for real-time workout tracking.

This post covers the full HealthKit workout lifecycle: authorization, reading step data, building and saving workout sessions, the mental health API, and the new iPhone/iPad-native workout sessions. It assumes you are familiar with async/await and SwiftUI state management. We won’t cover HealthKit queries in depth (anchored object queries, statistics collection queries) — those are covered in HealthKit: Reading Health Data.

Note: HealthKit is not available on iPad (prior to iPadOS 17) or Mac Catalyst. Always check HKHealthStore.isHealthDataAvailable() before accessing the API. All code in this post uses Swift 6 strict concurrency.

Contents

The Problem

Imagine you’re building a fitness tracking feature for a Pixar-themed wellness app — “Monsters, Inc. Scare Floor Fitness” — where users log workouts and track their daily steps toward a “scare energy” goal. The naive approach is to store workout data in your own database:

// The wrong approach — storing health data yourself
@Model
final class ScareFloorWorkout {
    var activityType: String
    var startDate: Date
    var endDate: Date
    var caloriesBurned: Double
    var steps: Int

    init(activityType: String, startDate: Date, endDate: Date,
         caloriesBurned: Double, steps: Int) {
        self.activityType = activityType
        self.startDate = startDate
        self.endDate = endDate
        self.caloriesBurned = caloriesBurned
        self.steps = steps
    }
}

This approach has three critical problems. First, your data is siloed — the user’s Apple Watch workouts, third-party app data, and system-collected steps are invisible to your app. Second, you’re duplicating sensitive health data outside the encrypted HealthKit store, creating privacy and compliance risks. Third, you miss automatic data collection — the iPhone’s motion coprocessor counts steps continuously without any app running.

HealthKit solves all three by acting as a centralized, encrypted, permission-gated health data store shared across the entire device.

Authorization and Setup

Apple Docs: HKHealthStore — HealthKit

Before touching any HealthKit data, you need three things: the HealthKit entitlement, Info.plist usage descriptions, and runtime authorization from the user.

Step 1 — Add the HealthKit capability in your Xcode project’s Signing & Capabilities tab. This adds the com.apple.developer.healthkit entitlement.

Step 2 — Add usage descriptions to your Info.plist:

<key>NSHealthShareUsageDescription</key>
<string>Monsters Inc Fitness reads your steps and workouts to calculate your daily scare energy.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Monsters Inc Fitness saves your workouts to the Health app.</string>

Step 3 — Request authorization at runtime:

import HealthKit

@Observable @MainActor
final class ScareFloorHealthManager {
    private let healthStore = HKHealthStore()
    private(set) var isAuthorized = false

    // Types we want to read
    private let readTypes: Set<HKSampleType> = [
        HKQuantityType(.stepCount),
        HKQuantityType(.activeEnergyBurned),
        HKQuantityType(.heartRate),
        HKObjectType.workoutType()
    ]

    // Types we want to write
    private let writeTypes: Set<HKSampleType> = [
        HKQuantityType(.activeEnergyBurned),
        HKObjectType.workoutType()
    ]

    func requestAuthorization() async throws {
        guard HKHealthStore.isHealthDataAvailable() else {
            throw ScareFloorError.healthDataUnavailable
        }

        try await healthStore.requestAuthorization(
            toShare: writeTypes,
            read: readTypes
        )

        // Note: HealthKit does not tell you whether the user
        // granted or denied read access — by design for privacy.
        isAuthorized = true
    }
}

enum ScareFloorError: LocalizedError {
    case healthDataUnavailable
    case workoutFinishFailed

    var errorDescription: String? {
        switch self {
        case .healthDataUnavailable:
            return "Health data is not available on this device."
        case .workoutFinishFailed:
            return "Failed to finish the workout session."
        }
    }
}

Warning: HealthKit intentionally hides read authorization status. authorizationStatus(for:) only returns meaningful results for write types. For read types, it always returns .notDetermined regardless of the user’s choice. This is a privacy measure — your app cannot determine whether a user denied read access or simply has no data. Design your UI to handle empty results gracefully.

Reading Step Count Data

Apple Docs: HKStatisticsQuery — HealthKit

For reading aggregated data like daily step counts, HKStatisticsQuery is the right tool. Here’s how to fetch today’s total steps — or in our Pixar universe, today’s “scare energy units”:

extension ScareFloorHealthManager {
    func fetchTodaySteps() async throws -> Double {
        let stepType = HKQuantityType(.stepCount)
        let startOfDay = Calendar.current.startOfDay(for: Date())
        let predicate = HKQuery.predicateForSamples(
            withStart: startOfDay,
            end: Date(),
            options: .strictStartDate
        )

        return try await withCheckedThrowingContinuation { continuation in
            let query = HKStatisticsQuery(
                quantityType: stepType,
                quantitySamplePredicate: predicate,
                options: .cumulativeSum
            ) { _, result, error in
                if let error {
                    continuation.resume(throwing: error)
                    return
                }

                let steps = result?.sumQuantity()?.doubleValue(
                    for: HKUnit.count()
                ) ?? 0
                continuation.resume(returning: steps)
            }

            healthStore.execute(query)
        }
    }
}

The withCheckedThrowingContinuation bridge is necessary because HKStatisticsQuery uses a completion handler, not async/await. The newer HKStatisticsQueryDescriptor (iOS 15.4+) supports async/await natively:

extension ScareFloorHealthManager {
    /// Async/await-native alternative using query descriptors
    @available(iOS 15.4, *)
    func fetchTodayStepsModern() async throws -> Double {
        let stepType = HKQuantityType(.stepCount)
        let startOfDay = Calendar.current.startOfDay(for: Date())
        let predicate = HKQuery.predicateForSamples(
            withStart: startOfDay,
            end: Date(),
            options: .strictStartDate
        )

        let descriptor = HKStatisticsQueryDescriptor(
            predicate: HKSamplePredicate<HKQuantitySample>.quantitySample(
                type: stepType,
                predicate: predicate
            ),
            options: .cumulativeSum
        )

        let result = try await descriptor.result(for: healthStore)
        return result?.sumQuantity()?.doubleValue(for: .count()) ?? 0
    }
}

Tip: For real-time step updates throughout the day, use HKStatisticsCollectionQuery with an update handler, or HKAnchoredObjectQuery for incremental delivery. These are covered in depth in HealthKit: Reading Health Data.

Writing Workout Sessions

Apple Docs: HKWorkoutBuilder — HealthKit

Writing a workout to HealthKit involves three steps: creating a workout builder, adding samples (calories, heart rate, distance) during the session, and finishing the builder to persist the workout. Here’s a complete workout manager for our Scare Floor training sessions:

extension ScareFloorHealthManager {
    func startWorkout(
        activityType: HKWorkoutActivityType
    ) async throws -> HKWorkoutBuilder {
        let configuration = HKWorkoutConfiguration()
        configuration.activityType = activityType
        configuration.locationType = .indoor

        let builder = HKWorkoutBuilder(
            healthStore: healthStore,
            configuration: configuration,
            device: .local()
        )

        try await builder.beginCollection(at: Date())
        return builder
    }

    func addCalorieSample(
        to builder: HKWorkoutBuilder,
        calories: Double,
        start: Date,
        end: Date
    ) async throws {
        let energyType = HKQuantityType(.activeEnergyBurned)
        let quantity = HKQuantity(
            unit: .kilocalorie(),
            doubleValue: calories
        )
        let sample = HKQuantitySample(
            type: energyType,
            quantity: quantity,
            start: start,
            end: end
        )

        try await builder.addSamples([sample])
    }

    func finishWorkout(
        _ builder: HKWorkoutBuilder
    ) async throws -> HKWorkout {
        try await builder.endCollection(at: Date())
        guard let workout = try await builder.finishWorkout() else {
            throw ScareFloorError.workoutFinishFailed
        }
        return workout
    }
}

The builder pattern is important — it lets you add samples incrementally during a workout (every few seconds for calorie burns, every heartbeat for heart rate) rather than constructing the entire workout object at the end. The builder also handles aggregation automatically, computing totals for the workout summary.

Here’s how the full flow looks from a SwiftUI view model:

@Observable @MainActor
final class ScareTrainingViewModel {
    private let healthManager: ScareFloorHealthManager
    private var workoutBuilder: HKWorkoutBuilder?
    private(set) var isTraining = false
    private(set) var elapsedTime: TimeInterval = 0
    private(set) var caloriesBurned: Double = 0

    private var startTime: Date?
    private var timer: Timer?

    init(healthManager: ScareFloorHealthManager) {
        self.healthManager = healthManager
    }

    func startTraining() async throws {
        workoutBuilder = try await healthManager.startWorkout(
            activityType: .functionalStrengthTraining
        )
        startTime = Date()
        isTraining = true
        startTimer()
    }

    func stopTraining() async throws {
        guard let builder = workoutBuilder else { return }
        timer?.invalidate()

        if let start = startTime {
            try await healthManager.addCalorieSample(
                to: builder,
                calories: caloriesBurned,
                start: start,
                end: Date()
            )
        }

        let workout = try await healthManager.finishWorkout(builder)
        workoutBuilder = nil
        isTraining = false
        print("Workout saved: \(workout.duration) seconds")
    }

    private func startTimer() {
        timer = Timer.scheduledTimer(
            withTimeInterval: 1.0,
            repeats: true
        ) { [weak self] _ in
            guard let self, let start = self.startTime else { return }
            Task { @MainActor in
                self.elapsedTime = Date().timeIntervalSince(start)
                // Simplified calorie estimation
                self.caloriesBurned = self.elapsedTime * 0.15
            }
        }
    }
}

Warning: Always call finishWorkout() to persist the workout. If the app is terminated while a workout builder is active, the workout data is lost. For production apps, consider saving intermediate state to handle unexpected termination — or use the workout session APIs discussed in the iOS 26 section below.

Mental Health APIs: HKStateOfMind

Apple Docs: HKStateOfMind — HealthKit

iOS 18 introduced HKStateOfMind, a sample type for logging emotional and psychological state. This is not just a mood tracker — the Health app integrates state of mind data with other health metrics to surface correlations (sleep quality affecting mood, exercise improving well-being). Your app can both write and read these samples.

import HealthKit

extension ScareFloorHealthManager {
    /// Log a monster's emotional state after a scare training session
    @available(iOS 18, *)
    func logPostWorkoutMood(
        valence: Double,
        labels: [HKStateOfMind.Label],
        associations: [HKStateOfMind.Association],
        kind: HKStateOfMind.Kind = .momentaryEmotion
    ) async throws {
        // valence: -1.0 (very unpleasant) to 1.0 (very pleasant)
        let stateOfMind = HKStateOfMind(
            date: Date(),
            kind: kind,
            valence: valence,
            labels: labels,
            associations: associations
        )

        try await healthStore.save(stateOfMind)
    }
}

The valence parameter is a continuous value from -1.0 to 1.0 representing the pleasantness dimension of emotion. Labels provide categorical descriptors (.happy, .anxious, .grateful, .stressed), and associations link the state to a context (.fitness, .work, .relationships).

Here’s how a post-workout mood check-in might look:

struct PostWorkoutMoodView: View {
    let healthManager: ScareFloorHealthManager
    @State private var valence: Double = 0.5
    @State private var selectedLabels: Set<HKStateOfMind.Label> = []

    var body: some View {
        VStack(spacing: 20) {
            Text("How do you feel after training, Sulley?")
                .font(.headline)

            Slider(value: $valence, in: -1...1, step: 0.1) {
                Text("Mood")
            } minimumValueLabel: {
                Image(systemName: "cloud.rain")
            } maximumValueLabel: {
                Image(systemName: "sun.max")
            }

            Button("Log Mood") {
                Task {
                    try? await healthManager.logPostWorkoutMood(
                        valence: valence,
                        labels: Array(selectedLabels),
                        associations: [.fitness]
                    )
                }
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

Reading State of Mind Data

You can also query historical state of mind samples to show trends — useful for correlating workout frequency with mood over time:

extension ScareFloorHealthManager {
    @available(iOS 18, *)
    func fetchRecentMoodEntries(
        days: Int = 30
    ) async throws -> [HKStateOfMind] {
        let startDate = Calendar.current.date(
            byAdding: .day,
            value: -days,
            to: Date()
        )!

        let predicate = HKQuery.predicateForSamples(
            withStart: startDate,
            end: Date(),
            options: .strictStartDate
        )

        let descriptor = HKSampleQueryDescriptor(
            predicates: [.stateOfMind(predicate)],
            sortDescriptors: [
                SortDescriptor(\.endDate, order: .reverse)
            ]
        )

        return try await descriptor.result(for: healthStore)
    }
}

Tip: The Health app’s mental health features include validated questionnaires (PHQ-9 for depression, GAD-7 for anxiety). Your app can read the results of these questionnaires through HealthKit if the user grants permission, but you cannot administer them — that’s handled exclusively by the Health app.

iPhone-Native Workout Sessions (iOS 26)

Apple Docs: HKWorkoutSession — HealthKit

Before iOS 26, HKWorkoutSession was an Apple Watch-only API. If you wanted real-time workout tracking (live calorie burn, heart rate zones, pause/resume), you needed a watchOS companion app. iOS 26 brings HKWorkoutSession to iPhone and iPad, meaning your app can now run workout sessions natively without an Apple Watch.

@available(iOS 26, *)
@Observable @MainActor
final class NativeWorkoutManager: NSObject {
    private let healthStore = HKHealthStore()
    private var session: HKWorkoutSession?
    private var builder: HKLiveWorkoutBuilder?

    private(set) var isActive = false
    private(set) var heartRate: Double = 0
    private(set) var activeCalories: Double = 0
    private(set) var elapsedTime: TimeInterval = 0

    func startWorkout(
        activityType: HKWorkoutActivityType
    ) async throws {
        let configuration = HKWorkoutConfiguration()
        configuration.activityType = activityType
        configuration.locationType = .indoor

        let session = try HKWorkoutSession(
            healthStore: healthStore,
            configuration: configuration
        )

        let builder = session.associatedWorkoutBuilder()
        builder.dataSource = HKLiveWorkoutDataSource(
            healthStore: healthStore,
            workoutConfiguration: configuration
        )

        session.delegate = self
        builder.delegate = self

        self.session = session
        self.builder = builder

        session.startActivity(with: Date())
        try await builder.beginCollection(at: Date())
        isActive = true
    }

    func pauseWorkout() {
        session?.pause()
    }

    func resumeWorkout() {
        session?.resume()
    }

    func endWorkout() async throws {
        session?.end()
        guard let builder else { return }
        try await builder.endCollection(at: Date())
        _ = try await builder.finishWorkout()
        isActive = false
    }
}

The key difference from the manual HKWorkoutBuilder approach is the HKLiveWorkoutDataSource. It automatically collects heart rate, active calories, and other metrics from the device’s sensors during the session. On iPhone, this uses the device’s accelerometer and gyroscope for motion-based metrics. Heart rate requires a connected Bluetooth heart rate monitor or an Apple Watch.

Implement the delegate methods to receive real-time updates:

@available(iOS 26, *)
extension NativeWorkoutManager: HKWorkoutSessionDelegate {
    nonisolated func workoutSession(
        _ workoutSession: HKWorkoutSession,
        didChangeTo toState: HKWorkoutSessionState,
        from fromState: HKWorkoutSessionState,
        date: Date
    ) {
        Task { @MainActor in
            isActive = toState == .running
        }
    }

    nonisolated func workoutSession(
        _ workoutSession: HKWorkoutSession,
        didFailWithError error: Error
    ) {
        print("Workout session failed: \(error)")
    }
}

@available(iOS 26, *)
extension NativeWorkoutManager: HKLiveWorkoutBuilderDelegate {
    nonisolated func workoutBuilder(
        _ workoutBuilder: HKLiveWorkoutBuilder,
        didCollectDataOf collectedTypes: Set<HKSampleType>
    ) {
        Task { @MainActor in
            if collectedTypes.contains(HKQuantityType(.heartRate)) {
                let stats = workoutBuilder.statistics(
                    for: HKQuantityType(.heartRate)
                )
                heartRate = stats?.mostRecentQuantity()?.doubleValue(
                    for: HKUnit.count().unitDivided(by: .minute())
                ) ?? 0
            }

            if collectedTypes.contains(
                HKQuantityType(.activeEnergyBurned)
            ) {
                let stats = workoutBuilder.statistics(
                    for: HKQuantityType(.activeEnergyBurned)
                )
                activeCalories = stats?.sumQuantity()?.doubleValue(
                    for: .kilocalorie()
                ) ?? 0
            }

            elapsedTime = workoutBuilder.elapsedTime
        }
    }

    nonisolated func workoutBuilderDidCollectEvent(
        _ workoutBuilder: HKLiveWorkoutBuilder
    ) {
        // Handle workout events (pause, resume, lap markers)
    }
}

This is a significant shift for fitness app developers. Previously, building a competitive workout app required maintaining both an iOS and watchOS target. With iOS 26, you can deliver a full workout tracking experience on iPhone alone — with Apple Watch support as an optional enhancement.

Note: iPhone-native workout sessions use the device’s built-in sensors. For outdoor activities, you get GPS-based distance and pace. For heart rate, you need either an Apple Watch or a Bluetooth heart rate monitor. The framework gracefully handles missing sensors — metrics that can’t be collected simply won’t appear in the workout summary.

Performance Considerations

Authorization UI delay. The HealthKit authorization sheet is system-managed and can take 1-2 seconds to appear. Never call requestAuthorization synchronously on the main thread in a way that blocks UI. Use a .task modifier or a button action with proper loading state.

Query execution. HealthKit queries run on a background queue internally. The completion handlers and async results are delivered off the main actor. Always dispatch UI updates back to the main actor. The @Observable @MainActor pattern in our examples handles this automatically.

Batch sample writes. When saving multiple samples (e.g., heart rate readings every 5 seconds during a workout), batch them with healthStore.save(_:) using an array rather than saving individually. Individual saves incur per-call overhead for database transactions.

// Batch save — much more efficient than individual saves
let samples: [HKQuantitySample] = heartRateReadings.map { reading in
    HKQuantitySample(
        type: HKQuantityType(.heartRate),
        quantity: HKQuantity(
            unit: .count().unitDivided(by: .minute()),
            doubleValue: reading.bpm
        ),
        start: reading.timestamp,
        end: reading.timestamp
    )
}
try await healthStore.save(samples)

Background delivery. For apps that need to respond to new health data while in the background (e.g., updating a complication or widget), use enableBackgroundDelivery(for:frequency:). Be aware this wakes your app at the system’s discretion — not immediately when data arrives. The .immediate frequency is only honored on Apple Watch.

Apple Docs: enableBackgroundDelivery(for:frequency:withCompletion:) — HealthKit

When to Use (and When Not To)

ScenarioRecommendation
Reading steps, heart rate, sleepUse HealthKit. The data is already collected by the device’s sensors.
Writing workout sessionsUse HKWorkoutBuilder or HKWorkoutSession (iOS 26+) for real-time.
Mood tracking / mental healthUse HKStateOfMind (iOS 18+). Integrates with Health app insights.
Custom fitness metrics not in HealthKitStore in your own database. You cannot add custom quantity types.
Social/competitive featuresRead from HealthKit, cache aggregates in your own backend.
Privacy-sensitive health dataPrefer HealthKit over storing health data in your own database.

Summary

  • HealthKit authorization requires an entitlement, Info.plist descriptions, and runtime permission. Read authorization status is intentionally hidden for privacy.
  • Use HKStatisticsQuery or HKStatisticsQueryDescriptor for aggregated data like daily step counts. Use HKSampleQuery for individual readings.
  • HKWorkoutBuilder lets you construct workouts incrementally — add samples during the session and call finishWorkout() to persist.
  • HKStateOfMind (iOS 18) enables mood and emotional state logging with valence, labels, and contextual associations.
  • iOS 26 brings HKWorkoutSession to iPhone and iPad, enabling real-time workout tracking with automatic sensor data collection — no Apple Watch required.

For a hands-on implementation of everything covered here, check out Build a Health and Fitness Tracker with HealthKit, which walks through building a complete fitness app with step tracking, workout logging, and chart visualizations.