Build Workout Zone Coaching With HealthKit
Workout zones are one of those fitness features that look like a charting problem until you try to ship them.
A good running app does not only say “you spent 18 minutes in Zone 3.” It helps the runner understand whether they stayed easy enough on a long run, whether an interval was hard enough, whether recovery actually happened between repeats, and whether the workout matched the plan.
That used to mean every app had to bring its own zone model, its own settings UI, and its own rules for sorting samples into buckets. In iOS 27 and watchOS 27, HealthKit supports heart-rate and cycling-power workout zones directly. Apple’s sample documentation for this feature is marked for Xcode 27, iOS 27, and watchOS 27.
The examples below use the API names from Apple’s WWDC26 session 207, “Deliver workout insights with HealthKit workout zones,” and the HealthKit documentation page “Tracking heart rate zones for workouts.” Treat them as new-SDK examples: recheck the current Xcode 27 documentation before shipping, especially while the SDKs are still moving.
This is not a WWDC roundup. Let’s talk about what you can actually build.
What HealthKit Gives You
HealthKit workout zones turn incoming workout samples into ordered intensity ranges. For iOS 27 and watchOS 27, the supported zone quantity types are heart rate and cycling power.
For completed workouts, HealthKit can give your app a zone group for the workout:
let heartRate = HKQuantityType(.heartRate)
if let zoneGroup = workout.zoneGroupsByType?[heartRate] {
// Use zoneGroup.configuration and zoneGroup.zoneDurations.
}
For multisport or structured workouts, you can also look at each HKWorkoutActivity:
let heartRate = HKQuantityType(.heartRate)
for activity in workout.workoutActivities {
if let zoneGroup = activity.zoneGroupsByType?[heartRate] {
// Render this activity's time-in-zone section.
}
}
That distinction matters. A triathlon summary that merges swim, bike, and run zones into one chart can hide the useful story. A per-activity view can show that the athlete rode steadily in Zone 2, then ran too hot in the first 10 minutes.
The returned HKWorkoutZoneGroup has two pieces:
configuration: anHKWorkoutZoneConfigurationdescribing the quantity type, source, and ordered zones.zoneDurations: the time spent in each zone.
The configuration is not decorative metadata. It tells you what “Zone 3” meant for this workout. HKWorkoutZoneConfiguration includes the zone quantity type, the source of the configuration, and ordered contiguous non-overlapping zones with minimum and maximum HKQuantity boundaries. The first and last zones are unbounded as needed so the full range is covered.
The source also matters. A configuration may come from system-calculated preferred zones, user-edited Health Settings, or an app-supplied custom configuration. Two workouts can both have a “Zone 3” label while using different boundaries or even a different number of zones.
Ask For The Smallest Authorization
Workout zone data follows the normal HealthKit permission model. Ask for the types your feature needs, and do not bundle cycling power into a heart-rate-only feature just because it exists.
A post-workout chart needs read access to workouts and the relevant quantity types. A workout-tracking app that saves workouts also needs share access for workouts.
import HealthKit
func requestWorkoutZoneAuthorization(
healthStore: HKHealthStore,
includeCyclingPower: Bool,
canSaveWorkouts: Bool
) async throws {
let workout = HKObjectType.workoutType()
let heartRate = HKQuantityType(.heartRate)
var readTypes: Set<HKObjectType> = [
workout,
heartRate
]
if includeCyclingPower {
readTypes.insert(HKQuantityType(.cyclingPower))
}
let shareTypes: Set<HKSampleType> = canSaveWorkouts ? [workout] : []
try await healthStore.requestAuthorization(
toShare: shareTypes,
read: readTypes
)
}
In a real app, split this by product surface. If the user only opens a historical analytics screen, request read access. If the user starts a workout in your app, request the workout write permission at that moment. If the app never coaches cycling power, do not ask for cycling power.
Fitness data is deeply personal. Make the permission screen explain the user benefit in product language: “show time in heart-rate zones after workouts” is clearer than “access HealthKit samples.”
Build A Time-In-Zone Chart
The simplest high-value feature is a post-workout time-in-zone chart.
Start by turning HKWorkoutZoneGroup into display rows. Keep the actual HKWorkoutZoneConfiguration nearby so you can show boundary labels, inspect the source, and avoid comparing incompatible zone systems later.
import Foundation
import HealthKit
struct ZoneDurationRow: Identifiable {
let id: Int
let zoneIndex: Int
var displayZoneNumber: Int { zoneIndex + 1 }
let duration: TimeInterval
}
func zoneRows(from group: HKWorkoutZoneGroup) -> [ZoneDurationRow] {
group.zoneDurations.map { zoneDuration in
ZoneDurationRow(
id: zoneDuration.zone.index,
zoneIndex: zoneDuration.zone.index,
duration: zoneDuration.duration
)
}
}
func heartRateZoneRows(for workout: HKWorkout) -> [ZoneDurationRow] {
let heartRate = HKQuantityType(.heartRate)
guard let group = workout.zoneGroupsByType?[heartRate] else {
return []
}
return zoneRows(from: group)
}
Then render the rows however your app normally charts workout summaries. With Swift Charts, a horizontal bar chart is compact and readable:
import Charts
import SwiftUI
struct TimeInZoneChart: View {
let rows: [ZoneDurationRow]
var body: some View {
Chart(rows) { row in
BarMark(
x: .value("Minutes", row.duration / 60),
y: .value("Zone", "Zone \(row.displayZoneNumber)")
)
.foregroundStyle(by: .value("Zone", "Zone \(row.displayZoneNumber)"))
}
.chartXAxisLabel("Minutes")
.frame(height: 220)
}
}
That is already useful, but the best summaries answer a product question:
- Easy run: “You stayed below Zone 3 for 84% of the run.”
- Tempo run: “You accumulated 22 minutes in Zones 3-4.”
- Recovery ride: “Most of the ride stayed in Zone 1, with two short spikes.”
- Interval workout: “Hard repeats reached Zone 4, but recoveries stayed elevated.”
The chart is the evidence. The coaching line is the value.
Add Per-Activity Views
If a workout has activities, build sections from HKWorkoutActivity.zoneGroupsByType instead of pretending the whole workout had one shape.
import HealthKit
struct ActivityZoneSection {
let activity: HKWorkoutActivity
let rows: [ZoneDurationRow]
}
func heartRateZoneSections(for workout: HKWorkout) -> [ActivityZoneSection] {
let heartRate = HKQuantityType(.heartRate)
return workout.workoutActivities.compactMap { activity in
guard let group = activity.zoneGroupsByType?[heartRate] else {
return nil
}
return ActivityZoneSection(
activity: activity,
rows: zoneRows(from: group)
)
}
}
This is where workout zones start feeling less like a generic graph and more like coaching. A multisport app can show each leg on its own. A structured running app can show warm-up, workout, and cool-down sections. A cycling app can separate outdoor ride segments, indoor blocks, or activity transitions if those are modeled as activities.
Use Live Zone Updates For Coaching
Post-workout charts are retrospective. Live zone updates are where coaching starts.
During an active workout, adopt the HKLiveWorkoutBuilderDelegate method didUpdateWorkoutZone to receive HKLiveWorkoutZoneUpdate values. The update includes the current zone, previous zone, a zone group with current totals, and the timestamp of the last processed sample. Apple’s transcript sample reads the active index through zoneUpdate.currentZoneDuration?.zone.index.
Here is the compact shape:
import Foundation
import HealthKit
import Combine
@MainActor
final class LiveZoneState: ObservableObject {
@Published var currentZoneIndex: Int?
@Published var zoneCount = 0
@Published var durations: [TimeInterval] = []
}
final class LiveWorkoutZoneDelegate: NSObject, HKLiveWorkoutBuilderDelegate {
private let state: LiveZoneState
init(state: LiveZoneState) {
self.state = state
}
func workoutBuilder(
_ workoutBuilder: HKLiveWorkoutBuilder,
didCollectDataOf collectedTypes: Set<HKSampleType>
) {
// Handle normal live metric updates elsewhere.
}
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
// Handle pauses, resumes, laps, and other workout events elsewhere.
}
func workoutBuilder(
_ workoutBuilder: HKLiveWorkoutBuilder,
didUpdateWorkoutZone zoneUpdate: HKLiveWorkoutZoneUpdate
) {
guard let zoneGroup = zoneUpdate.zoneGroup else {
return
}
let currentIndex = zoneUpdate.currentZoneDuration?.zone.index
let durations = zoneGroup.zoneDurations.map(\.duration)
let zoneCount = zoneGroup.configuration.zones.count
Task { @MainActor in
state.currentZoneIndex = currentIndex
state.durations = durations
state.zoneCount = zoneCount
}
}
}
In a production target, wrap the live zone pieces in the SDK availability that Apple ships for your deployment target. The sample documentation is marked for Xcode 27, iOS 27, and watchOS 27.
Do not treat this as a raw sample stream. Zone updates happen when the current zone changes, and the update gives you cumulative totals in the zone group. Use the update to change the UI state, trigger a haptic, or start a local timer from the last processed sample timestamp so the current-zone duration continues to feel alive between updates.
The product design matters more than the delegate method:
- For an easy run, warn only after the user spends a few seconds above the target zone.
- For intervals, give a gentle “build” cue before the work block and a stronger cue if the target zone is missed.
- For recovery, coach the user to wait until they drop below a zone before starting the next repeat.
- For cycling power, show target power zone and time remaining in the block.
Avoid turning the watch into a scolding machine. A zone change from Zone 2 to Zone 3 is a signal, not automatically a problem.
Respect Preferred Zones First
By default, HealthKit uses preferred workout zone thresholds from Health Settings. That is usually the best default for general fitness apps because the user gets a consistent experience across apps and devices.
Preferred zones can be system-calculated or manually configured by the user. They sync across devices. If your app says “Zone 2,” and another app says “Zone 2,” preferred zones give those words a better chance of meaning the same thing for that user.
Before starting a workout that depends on zones, check whether a preferred configuration is available. Apple’s sample checks through the workout builder:
let heartRate = HKQuantityType(.heartRate)
if let configuration = try await builder.zoneConfiguration(for: heartRate) {
// Show a preview, start live coaching, or record the source for your UI.
_ = configuration.source
} else {
// Offer a custom fallback, or start without zone coaching.
}
Apple also describes querying preferred zones on HKHealthStore, which is useful for screens that appear before a live HKWorkoutBuilder exists.
A good product flow is:
- Use preferred zones when they exist.
- Tell the user when zone coaching is unavailable because no preferred configuration exists.
- Offer an app-specific custom configuration only when the app has a real training reason.
That last point is important. Custom zones are powerful, but they also create another place where “Zone 3” can stop meaning what the user expects.
Add Custom Zones When Your App Owns The Model
Custom zones make sense when your app has a specific training model: a cycling platform with FTP-based zones, a running coach with lab-tested threshold ranges, or a team-training app that must match a coach’s prescribed zones.
Create an HKWorkoutZoneConfiguration with the quantity type and boundaries, then call builder.setCustomZoneConfiguration(_:for:) before beginCollection.
import HealthKit
enum WorkoutZoneSetupError: Error {
case invalidZoneCount(Int)
}
func setFallbackHeartRateZones(on builder: HKWorkoutBuilder) async throws {
let heartRate = HKQuantityType(.heartRate)
let bpm = HKUnit.count().unitDivided(by: .minute())
// Four internal boundaries create five zones.
let thresholds = [91.0, 114.0, 136.0, 158.0]
let zoneCount = thresholds.count + 1
guard (3...9).contains(zoneCount) else {
throw WorkoutZoneSetupError.invalidZoneCount(zoneCount)
}
let boundaries = thresholds.map { value in
HKQuantity(unit: bpm, doubleValue: value)
}
let defaultConfiguration = try HKWorkoutZoneConfiguration(
quantityType: heartRate,
zoneBoundaries: boundaries
)
try await builder.setCustomZoneConfiguration(
defaultConfiguration,
for: heartRate
)
}
And the ordering around collection matters:
if try await builder.zoneConfiguration(for: HKQuantityType(.heartRate)) == nil {
try await setFallbackHeartRateZones(on: builder)
}
try await builder.beginCollection(at: Date())
Custom configurations are scoped to the workout. HealthKit uses the configuration for that workout and exposes it through the workout’s zone group, but it does not become the user’s preferred setting and it does not become your app’s cross-device settings system. If your app lets people define custom zones, your app owns persistence, sync, editing, migration, and deletion.
Also watch the boundary math. Custom configurations require between 3 and 9 zones. Because the first and last zones are unbounded as needed, your internal threshold count is usually one fewer than the zone count. The boundary quantities must use units compatible with the configuration’s quantity type.
Do Not Compare Zone Numbers Blindly
The biggest analytics trap is treating zone labels as universal.
Zone 3 in a 5-zone heart-rate configuration is not automatically equivalent to Zone 3 in a 7-zone cycling-power configuration. Zone 3 for one user may represent a different physiological range than Zone 3 for another user. Even for the same user, preferred zones and custom app zones can differ.
If your app compares workouts, do one of these instead:
- Compare against each workout’s own plan: “Did this workout stay under its target zone?”
- Compare boundary-aware ranges: “How much time was above this heart-rate threshold?”
- Rebucket original samples into your app’s canonical model when you need an app-wide training score.
- Store the configuration source, quantity type, zone count, and boundaries alongside derived analytics.
This is especially important for social features. A leaderboard for “most Zone 5 minutes” sounds simple, but it is not fair unless every participant uses comparable boundaries. For community features, normalize the data or compare goal completion instead of raw zone labels.
Privacy Shapes The Feature
HealthKit gives users control over access, and your app should keep that control visible.
A few practical rules help:
- Request heart rate for heart-rate coaching, and cycling power only for cycling-power coaching.
- Keep zone coaching usable when a permission is denied; show the workout timer and distance even if zones are unavailable.
- Avoid uploading raw samples unless the user clearly opted into coaching, cloud sync, or coach review.
- If you sync derived zone summaries, treat them as health data in your privacy model.
- Give users a way to delete app-owned custom zone settings and server-side summaries.
There is also a tone issue. “Your heart-rate permission is missing” sounds like the user broke the app. “Turn on heart-rate access to see time in zones” makes the benefit clear and leaves the decision with them.
Real App Ideas
Here are the ideas I would reach for first.
A running app can show a target-zone ribbon during the workout and a simple compliance card afterward: “Goal: stay mostly in Zones 1-2. Result: 88% easy.”
A cycling app can combine power zones with interval steps: “Hold Zone 4 for 6 minutes,” then show whether each repeat hit the target. For outdoor rides, summarize climbs separately from flats if you model those as activities or segments.
A strength and conditioning app can use heart-rate zones for recovery pacing between circuits. Instead of a fixed 60-second rest timer, the next set begins when the athlete drops below a recovery zone.
A coaching platform can let coaches prescribe custom zones for a training block, then save the exact configuration used for each workout so later analysis remains honest.
A wellness app can keep the feature gentle: after a walk, show “mostly easy” or “moderate effort” without making the user learn the entire zone model.
A race-prep app can compare planned intensity to actual intensity over weeks, not just one workout. It can answer, “Are your easy days easy enough?” and “Are your hard days actually hard?”
Those are all zone features, but they are not the same product. The API gives you the totals. Your app decides what those totals mean.
Practical Checklist
- Gate this work for Xcode 27, iOS 27, and watchOS 27 until the final SDK availability is confirmed.
- Request HealthKit authorization for workouts, heart rate, and cycling power only as needed.
- Use
workout.zoneGroupsByType?[HKQuantityType(.heartRate)]for completed workout summaries. - Use
HKWorkoutActivity.zoneGroupsByTypewhen per-activity charts tell a clearer story. - Read
HKWorkoutZoneGroup.configurationbefore interpretingzoneDurations. - Use
HKLiveWorkoutBuilderDelegate.didUpdateWorkoutZonefor live coaching. - Prefer Health Settings zones when they exist, because they sync across devices.
- Use custom
HKWorkoutZoneConfigurationonly when your app owns a real training model. - Call
builder.setCustomZoneConfiguration(_:for:)beforebeginCollection. - Keep custom configurations between 3 and 9 zones.
- Never compare “Zone 3” across users, workouts, or configurations without checking boundaries and zone counts.