iRunning · watchOS

Keeping a watchOS workout alive without wrecking the battery

A normal watchOS app gets suspended within seconds of the wrist dropping. A fitness app can't afford that — a two-hour run has to keep counting the whole way. The trick isn't a background mode. It's HealthKit, and then a long, unglamorous fight with the battery.

The first hard lesson of building iRunning for the watch was that the platform actively wants to kill you. watchOS is ruthless about reclaiming a tiny battery: lower your wrist and the app suspends, the timers stop, and Core Location goes quiet. For most apps that's correct behaviour. For one whose entire job is to measure the next ninety minutes without a single gap, it's an existential problem. The good news is that Apple solved it for us — just not where most people look first.

Why a normal app gets suspended — and a workout doesn't

People come to this from the iOS side and reach for background modes: the location capability, audio tricks, the old standbys. On watchOS that's the wrong tool, and it shows. The system treats a backgrounded app as a candidate for suspension regardless of how many capabilities you've ticked, and you'll spend a week fighting a fight you can't win.

The thing that actually changes the rules is an active HKWorkoutSession. When you start one, you're telling the system "the person is exercising and this app is the one recording it." That declaration is what keeps your process alive in the background, keeps your location updates flowing, and keeps your timers ticking. It's not a hack you're getting away with — it's the sanctioned path, and the system grants it because a workout is exactly the kind of long-running, user-initiated activity the watch was built for. The corollary is the part people miss: the moment that session ends, the privileges evaporate and you're back to being a suspendable app.

The session lifecycle: start, pause, end

A workout session is a small state machine, and you have to respect its states or HealthKit will. You build it from an HKWorkoutConfiguration, attach a builder that collects the samples, and drive it through .running, .paused, and .ended. Pausing genuinely matters: a paused session stops accruing active energy and, more to the point for this article, is your cue to back off the sensors. Ending is final — you can't resume an ended session, so a "pause" button must map to pause(), never to end().

import HealthKit

final class WorkoutController: NSObject {
    private let store = HKHealthStore()
    private var session: HKWorkoutSession?
    private var builder: HKLiveWorkoutBuilder?

    func startRun() throws {
        let config = HKWorkoutConfiguration()
        config.activityType = .running
        config.locationType = .outdoor

        let session = try HKWorkoutSession(healthStore: store,
                                           configuration: config)
        let builder = session.associatedWorkoutBuilder()
        builder.dataSource = HKLiveWorkoutDataSource(healthStore: store,
                                                     workoutConfiguration: config)
        session.delegate = self
        builder.delegate = self

        let start = Date()
        session.startActivity(with: start)          // privileges begin here
        builder.beginCollection(withStart: start) { _, _ in }

        self.session = session
        self.builder = builder
    }

    func pause()  { session?.pause() }              // back off the sensors
    func resume() { session?.resume() }

    func end() {
        session?.end()                              // privileges end here
        builder?.endCollection(withEnd: Date()) { [weak self] _, _ in
            self?.builder?.finishWorkout { _, _ in }   // persist to HealthKit
        }
    }
}

Letting HKLiveWorkoutBuilder own the heart-rate and energy samples is worth it. It pulls from the same pipeline the built-in Workout app uses, which means it's already tuned for battery, and it writes a real HKWorkout to the health store at the end — the thing that makes a run show up in the Fitness rings instead of living only inside your app.

What actually drains the battery on a two-hour run

Once the session keeps you alive, the problem inverts. You can run for two hours; the question is whether the watch survives it. I instrumented a long session and the culprits, in rough order of damage, were never surprising in hindsight:

  • GPS. Core Location with full accuracy, polling every second, is the single most expensive thing a running app does. It dwarfs everything else.
  • Screen wake-ups. Every wrist raise lights the display, and on always-on watches the dimmed face still costs you. Over two hours that's hundreds of wakes.
  • Heart-rate sampling. The optical sensor is cheaper than GPS but not free, and the cadence is partly yours to influence.
  • Your own CPU. Filtering, distance math, and SwiftUI redraws on every fix add up — and unlike the sensors, this part is entirely your fault.

The headline number from that session: full-accuracy 1 Hz GPS for the entire run drew roughly 35–40% of a Series watch's battery on its own. With the duty-cycling below, I got a comparable run down to the low twenties without the route quality suffering in any way a runner would notice.

Duty-cycling location when the pace is steady

The insight that bought the most: a runner holding a steady pace down a straight road does not need a fix every second at the highest accuracy. You only need that density at the start (cold fix), around turns, and when speed changes. So I run the location manager adaptively — best accuracy with a tight distanceFilter by default, and when the recent pace has been stable I relax both, then tighten back up the moment things change.

import CoreLocation

final class RunLocationManager: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()

    override init() {
        super.init()
        manager.delegate = self
        manager.activityType = .fitness            // tells CL this is a workout
        manager.allowsBackgroundLocationUpdates = true
        tighten()                                  // start precise for the cold fix
    }

    func start() { manager.startUpdatingLocation() }

    /// Precise mode: every metre, best fix. Use at the start & around turns.
    private func tighten() {
        manager.desiredAccuracy = kCLLocationAccuracyBestForNavigation
        manager.distanceFilter  = 5
    }

    /// Relaxed mode: coarser & sparser. Use when pace is steady.
    private func relax() {
        manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
        manager.distanceFilter  = 20
    }

    func locationManager(_ m: CLLocationManager,
                         didUpdateLocations locations: [CLLocation]) {
        guard let fix = locations.last, fix.horizontalAccuracy > 0 else { return }
        // paceIsSteady() compares the last few fixes' speed & course variance.
        paceIsSteady(using: locations) ? relax() : tighten()
        // ... hand the gated fix to the filter (off this callback's hot path)
    }
}

Two settings here matter more than they look. activityType = .fitness lets Core Location apply workout-aware heuristics — it knows a runner's motion profile and budgets accordingly. And kCLLocationAccuracyBestForNavigation is genuinely more expensive than plain kCLLocationAccuracyBest; reserve it for when you actually need turn-by-turn precision, not as a default you forget to dial back.

Pause means pause the sensors

When the session goes to .paused, stop being greedy. Call stopUpdatingLocation() and let the builder idle. A runner stopped at a crossing doesn't need 1 Hz GPS, and an auto-paused session that keeps the sensors hot is just burning battery to record someone standing still.

Keeping CPU work off the workout's path

The sensors get the blame, but on a long run your own code is a quiet drain. Every location callback that triggers a Kalman step, a distance recomputation, and a full SwiftUI invalidation is a little spike, and at one per second for two hours those spikes are a real fraction of the budget. Worse, doing heavy work synchronously inside the delegate callback can stall the very pipeline that's delivering fixes.

So I keep the delegate callbacks lean: validate the fix, push it onto a queue, and return. The filtering and distance math happen off the main actor, and the UI updates on a throttle — a runner glancing at the watch can't perceive faster than about once a second anyway, so redrawing the pace label at 60 fps is pure waste. Batching is the other lever: HealthKit and Core Location will hand you several samples at once if you let them, and processing a batch is cheaper than waking up for each one.

Testing battery honestly, on a real wrist

You cannot measure any of this in the simulator. The simulator has no GPS radio, no optical heart sensor, and no real power draw — it'll happily tell you everything is fine. The only honest test is a real device on a real arm for a real duration, and it's tedious in exactly the way that makes people skip it.

What I do: charge to 100%, note the level, then run a genuine 60-to-90-minute session outdoors with the screen behaving as a runner's would — wrist raises, glances, the lot. Note the drain. Change one variable — the distanceFilter, the accuracy tier, the UI throttle — and run the same loop again on another day. It's slow, but it's the only way to attribute a percentage to a decision. Xcode's energy gauges and a signpost around the per-fix work tell you where the CPU goes; only the wrist tells you what the radios cost. And test on the oldest watch you still support, because the newest one's bigger battery will mask sins that an older model won't forgive.

The throughline is a mindset more than a technique: on watchOS, battery is a feature, and it deserves the same discipline as any feature you'd put on a roadmap. Start the HKWorkoutSession so the system keeps you alive, then spend the rest of your effort earning that privilege — duty-cycle the GPS, respect pause, keep your own CPU off the hot path, and measure on real hardware instead of hoping. A run that dies at ninety minutes because the watch did isn't a run you recorded. Getting the runner all the way to the finish line, with battery to spare, is the whole job.

← All writing