watchOS Runtime Is a Contract, Not a Background Task

Genre: shipped-code. The post documents the watchOS runtime pattern Return ships in production. Return is the SwiftUI meditation timer on the App Store; the Watch app runs a multi-cycle timer that has to keep counting when the user drops the wrist.1 The pattern that survives that constraint is WKExtendedRuntimeSession plus a global, app-scoped delegate. Everything else dies the moment the watch sleeps.

watchOS is not iOS with a smaller screen. The runtime model is different. iOS gives an app a generous foreground budget and a dwindling-but-real background runtime through audio sessions, location updates, BGTaskScheduler, and a handful of other affordances.2 watchOS gives the foreground app a budget measured in seconds after wrist drop, and after that, the app is suspended unless it has signed a runtime contract with the system. There is no “I’m just doing a thing in the background” affordance. There is “I am running a workout, mindfulness session, smart alarm, navigation route, or health monitoring task,” and nothing else.3

Return’s Watch target is a mindfulness timer. The session contract is WKBackgroundModes: mindfulness. The runtime API is WKExtendedRuntimeSession. The pattern that took the Watch app from broken on wrist drop to survives a 25-minute meditation is the one this post describes.

TL;DR

  • watchOS does not have an iOS-style background. Foreground runtime ends shortly after wrist drop, and only registered session types continue running.
  • WKExtendedRuntimeSession is the API surface. The session must declare a session type; for a meditation timer, the type is implicit through WKBackgroundModes: mindfulness in Info.plist.
  • The session manager has to live at app scope, not view scope. SwiftUI’s view lifecycle deallocates view-owned objects on navigation; a deallocated session delegate is a dead session, even if the session itself is still running.
  • The WKExtendedRuntimeSessionDelegate callbacks are the contract: didStart, willExpire, didInvalidateWith. The expire callback fires before the system forces invalidation; Apple describes it as the window for the app to “finish and clean up.”
  • A wrist drop without an active extended session pauses the timer. A wrist drop with an active extended session continues the timer. The session is the difference between “shipped product” and “broken on second use.”

The Background Problem watchOS Does Not Solve The iOS Way

iOS apps reach for several background affordances when they need the app to keep running with the screen off:2

  • An AVAudioSession with the .playback category keeps an audio app alive while music plays.
  • CLLocationManager background updates keep a navigation app alive with a blue bar.
  • BGTaskScheduler queues short maintenance work that the system schedules on its own clock.
  • A foreground UI extension (Live Activity, CallKit, PushKit) bridges the app process to a system-controlled rendering surface.

None of those help on watchOS in the way you might assume. Watch apps do not have the same background-task scheduler. They do not have a backgrounded AVAudioSession.playback mode that keeps the timer counting in silence. They have one structural primitive for “I want to keep running after the user drops the wrist,” and the primitive is WKExtendedRuntimeSession with a declared session type.3

The session types Apple supports through WKBackgroundModes are narrow on purpose:4

  • workout-processing (with HKWorkoutSession for actual workouts)
  • mindfulness (for meditation timers and breathing exercises)
  • self-care (for guided routines)
  • physical-therapy (for therapy session apps)
  • alarm (for time-based wake-up alarms)
  • underwater-depth (for diving and depth-tracking apps)

Apps that do not fit one of those categories cannot use WKExtendedRuntimeSession to run after wrist drop. Audio apps reach for the mediaPlayback audio session category and Now Playing integration on a different code path; navigation apps use CLLocationManager background updates. The Watch is not a general-purpose computer; it is a device with battery constraints that the runtime model enforces.

A meditation timer fits mindfulness. The contract: declare the background mode in Info.plist, request a WKExtendedRuntimeSession, handle the delegate callbacks, end the session when the timer ends. The system grants up to roughly an hour of runtime per session, with the system’s own discretion to shorten that under thermal or battery pressure.3

The Pattern Return Ships

The pattern starts with the Info.plist declaration:4

<key>WKBackgroundModes</key>
<array>
    <string>mindfulness</string>
</array>

The mode declaration is what makes the session type valid. Without it, calling WKExtendedRuntimeSession().start() fails silently and the app suspends on wrist drop just like a Watch app with no background mode at all.

The session manager itself has to live at app scope. SwiftUI’s view lifecycle is unfriendly to long-lived stateful objects: @StateObject and @State are scoped to the view that owns them, and a navigation push that replaces the view drops the state with it. A WKExtendedRuntimeSession whose delegate gets deallocated mid-session does not crash; the session continues running, but the delegate callbacks (willExpire, didInvalidateWith) reach a freed object, which means cleanup never happens, which means the next startSession() call thinks there is no active session and starts a duplicate.

The shipped pattern is a singleton at app scope. The snippet below is the structural shape; production adds logging inside each method for observability:

import SwiftUI
import WatchKit

final class WatchSessionManager: NSObject, WKExtendedRuntimeSessionDelegate {
    static let shared = WatchSessionManager()

    private var session: WKExtendedRuntimeSession?

    private override init() {
        super.init()
    }

    var isSessionActive: Bool {
        session != nil
    }

    func startSession() {
        guard session == nil else { return }
        let newSession = WKExtendedRuntimeSession()
        newSession.delegate = self
        newSession.start()
        session = newSession
    }

    func endSession() {
        guard let existing = session else { return }
        existing.invalidate()
        session = nil
    }

    // MARK: - WKExtendedRuntimeSessionDelegate

    func extendedRuntimeSessionDidStart(_ session: WKExtendedRuntimeSession) {}

    func extendedRuntimeSessionWillExpire(_ session: WKExtendedRuntimeSession) {
        // Apple's "about to expire / finish and clean up" hook
    }

    func extendedRuntimeSession(
        _ session: WKExtendedRuntimeSession,
        didInvalidateWith reason: WKExtendedRuntimeSessionInvalidationReason,
        error: Error?
    ) {
        self.session = nil
    }
}

Three details about that singleton that the docs do not call out:

The static let shared plus @State private var sessionManager = WatchSessionManager.shared at the @main App level keeps the manager alive for the lifetime of the watch app process. SwiftUI does not retain singletons just because a view holds them; the binding above is what tells the runtime to keep the reference. Without the App-level binding, ARC can drop the manager when no view holds it.

The session property is the protection against duplicate sessions. A timer with a “start over” button can call startSession() from multiple paths; the guard session == nil check is the lock. Two concurrent extended sessions cause unpredictable behavior: sometimes the second succeeds and the first becomes orphaned, sometimes the start call fails silently. The single-session invariant prevents the entire class.

The delegate callbacks log but rarely act. The didStart callback fires once per session and is a useful hook for observability; the willExpire callback fires before the system forces invalidation and is where Apple expects the app to “finish and clean up”; the didInvalidateWith callback is where the session reference clears so the next startSession() call works. The pattern in production is callbacks update state, the state machine does the work, not callbacks do the work directly.

The timer manager calls into the session manager at every transition that changes whether the timer is actively counting:

final class WatchTimerManager: ObservableObject {
    func start() {
        startExtendedSession()        // -> WatchSessionManager.shared.startSession()
        // ... start the timer state machine ...
    }

    func pause() {
        timer?.invalidate()
        isRunning = false
        endExtendedSession()          // -> WatchSessionManager.shared.endSession()
    }

    func reset() {
        // ... clear timer state ...
        endExtendedSession()
    }

    private func completeCycle() {
        // ... last cycle handling ...
        endExtendedSession()          // ends on final completion
    }
}

The session ends on pause, on reset, and on the final cycle’s completion. The product reasoning: a paused meditation does not need to keep claiming runtime budget the system grants under mindfulness; resume from pause re-acquires a fresh session. The product cost is that a wrist-dropped pause cannot be resumed-by-wrist-raise alone; the user has to bring the app back to the foreground to resume. The product win is that paused-timer battery cost goes to zero and the system does not see a stale session.

The Wrist Drop Is The Test

watchOS testing in the simulator is a polite fiction. The simulator does not enforce the wrist-drop runtime model the way a real Apple Watch does. The simulator keeps the app foregrounded as long as the simulator window has focus; an extended runtime session in the simulator looks identical to no session at all, because the foreground app keeps running either way.

The actual test is on a real Apple Watch:5

  1. Launch the timer.
  2. Drop the wrist (or press the side button to lock the screen).
  3. Wait 30 seconds.
  4. Raise the wrist back.

Without an active extended runtime session, the watch app is suspended; the timer state is frozen at the moment of wrist drop and resumes from that frozen state. For a 5-minute meditation that has the user closing their eyes, the bug is invisible until the timer is wrong by however long the eyes were closed.

With an active extended runtime session, the timer keeps counting. The wrist raise reveals the timer at the correct elapsed position. The audio cue (if the timer plays one at completion) fires at the correct wall-clock time, not the raised-wrist time.

The wrist-drop scenario was the bug Return shipped in v1 and patched in v2. The fix is the singleton pattern above; the bug was a WatchSessionManager instance held by a SwiftUI view that got deallocated on navigation push. The session was technically running on the system side, but the delegate was freed; the next session-start call was silently a no-op because the manager’s session property had been set on a now-dead object. Real-device testing surfaces the failure in seconds. Simulator testing surfaces it never.

What The Delegate Callbacks Actually Tell You

WKExtendedRuntimeSessionInvalidationReason enumerates the ways a session ends:6

Reason When it happens
none The session was explicitly invalidated by the app calling invalidate()
sessionInProgress A session of the same type is already running
expired The system-imposed time limit was reached
resignedFrontmost A different app became frontmost while the session ran
suppressedBySystem The system suppressed the session (low power, thermal pressure)
error An unrecoverable error occurred; check the error parameter

The reasons that matter for product design:

expired means the user got the full session you asked for. The session ran to its natural end. Return’s longest meditation duration is 60 minutes, which is right at the edge of what mindfulness sessions are typically granted. A 90-minute meditation would routinely hit expired and the timer would die mid-session. The product decision is to cap available durations to what the runtime model can actually deliver.

resignedFrontmost means the user opened another Watch app and your session lost. Watch users are good at swiping to a different app and then forgetting. The product decision is to either pause-on-resign (state preserved, user can come back) or end-on-resign (session over, user gets a “you stopped early” signal). Return picks pause-on-resign so the user can take a phone call mid-meditation and come back.

suppressedBySystem is the polite version of “the watch is hot.” A watchOS device under thermal pressure or low battery may revoke an extended runtime session even without app misuse. The session manager has to handle the case gracefully: clear the reference, surface a non-blocking warning, and not enter a state where it tries to restart a session the system just refused.

The willExpire callback fires when the session is about to expire and is documented as the moment for the app to “finish and clean up.”3 The callback is where an app can write a final state snapshot, play a closing audio cue, or present a “session ending soon” UI. Return today only logs the callback; richer cleanup (HealthKit log entry, audio fade-out) happens on the timer’s reset and completion paths and is on the what I would build differently list for the willExpire window.

What I Would Build Differently

Two things, if Return were starting from scratch.

Use HKWorkoutSession for any session whose value increases with HealthKit integration. A meditation timer sits on the edge between mindfulness and workout-processing. Mindfulness was the right choice for v1 because the data model is simpler and the user expectation is “this is meditation, not a workout.” HKWorkoutSession carries more granular HealthKit integration (session start, session end, segments, events) and gives a richer LiveWorkoutBuilder interface for accumulating data. The architectural judgment, not a documented Apple guarantee: for an app whose value depends on detailed session telemetry, the workout-session route handles structure that WKExtendedRuntimeSession does not.

Add a session-state observability surface from day one. The first version of Return logged session events to console. The second version added on-device session-state visibility for debugging. The third would expose a developer-mode toggle that surfaces the session reason history to the user when something goes wrong, instead of treating session invalidation as a black box. The watchOS runtime is opaque; the debug surface needs to compensate.

When WKExtendedRuntimeSession Is The Wrong Answer

Three cases where the session type does not fit:

Workouts that need segment markers, heart rate streams, or active calorie tracking. Use HKWorkoutSession directly with an HKLiveWorkoutBuilder. The Workout API is Apple’s documented path for actual workouts (and walking meditations or strenuous activity); WKExtendedRuntimeSession is the documented path for non-workout sessions like mindfulness or alarms. A meditation app does not need a workout; a Couch-to-5K app does.

Audio playback that needs a Now Playing surface. Use an AVAudioSession configured for playback alongside watchOS audio session entitlements; the Now Playing integration plus the system playback surface is what audio apps want, and the audio path is separate from WKExtendedRuntimeSession entirely. WKExtendedRuntimeSession does not give you Now Playing or the system audio routing.

Long-running data sync without user awareness. Use WKApplicationRefreshBackgroundTask for periodic refresh windows the system schedules. The user is not in the app; the app does not need to keep running; it needs to wake up briefly and refresh. The two background-task and extended-runtime-session models serve very different needs.

What The Pattern Means For Apps Shipping On watchOS 11+

Three takeaways.

  1. The Watch runtime model is opt-in. Pick a session type and live inside its rules. Apps that try to do “general background work” on watchOS will lose. Pick mindfulness, workout-processing, self-care, physical-therapy, alarm, or underwater-depth, and design the user experience around the runtime budget that comes with the session type you chose.

  2. The session delegate has to live at app scope. SwiftUI’s view lifecycle does not protect long-lived stateful objects. A static let shared singleton bound at the @main App level is the smallest pattern that survives navigation pushes, view replacements, and SwiftUI’s normal deallocation behavior.

  3. Test on real hardware. The simulator does not enforce the wrist-drop runtime model. The bug a Watch app cannot test in the simulator is the bug it ships to users.

Pair this post with my prior writeups on the same family of apps: cross-platform SwiftUI shipping (Return ships on iPhone, iPad, Watch, Mac, and Apple TV); the Live Activities state machine (the iOS-side surface for the same timer); HealthKit patterns (where the Watch’s mindfulness sessions land in the user’s Health data). The full set lives at the Apple Ecosystem Series hub. For broader iOS-with-AI-agents context, see the iOS Agent Development guide.

FAQ

What is a watchOS extended runtime session?

A watchOS extended runtime session (WKExtendedRuntimeSession) is the API a Watch app uses to keep running after the user drops the wrist. The session must declare a type (mindfulness, workout-processing, alarm, etc.) through WKBackgroundModes in Info.plist. Without an active extended session, watchOS suspends the app shortly after wrist drop.

Why does my watchOS timer stop counting when the user drops the wrist?

The Watch app suspends shortly after wrist drop unless an active WKExtendedRuntimeSession of a supported type is running. A timer manager that does not start such a session will see its background runtime cut off, and the timer state freezes at the moment of wrist drop until the user raises the wrist again.

What’s the difference between WKExtendedRuntimeSession and HKWorkoutSession?

WKExtendedRuntimeSession is the general-purpose extended runtime API for non-workout sessions like mindfulness, alarm, or self-care. HKWorkoutSession is the API for actual workouts; it integrates with HealthKit, supports segment markers, and is the documented path for walking meditations or strenuous activity. Mindfulness apps without workout-grade telemetry use the first; workout apps use the second.

Can the system revoke my extended runtime session?

Yes. WKExtendedRuntimeSessionInvalidationReason includes expired (system time limit reached), resignedFrontmost (another Watch app became frontmost), and suppressedBySystem (low power or thermal pressure). The session manager has to handle each cleanly: the reference clears, the timer state reacts appropriately, and the next session start call works correctly.

Where should the session manager live in a SwiftUI watchOS app?

At app scope, as a singleton bound from the @main App struct. SwiftUI’s view-scoped state (@State, @StateObject) gets deallocated on navigation pushes, view replacements, or app backgrounding. A view-owned session delegate that is freed mid-session causes the session reference to leak and prevents subsequent sessions from starting cleanly.

References


  1. Author’s Return, a SwiftUI meditation timer published on the App Store on April 21, 2026, available for iPhone, iPad, Mac, Apple Watch, and Apple TV. Watch app uses WKExtendedRuntimeSession with mindfulness background mode for cycle-timer runtime. 

  2. Apple Developer, “About the background execution sequence”. iOS-side background runtime affordances (audio sessions, location, BGTaskScheduler) and how they differ from watchOS. 

  3. Apple Developer, “WKExtendedRuntimeSession”. Session types, lifecycle, delegate callbacks, runtime limits, and the WKBackgroundModes Info.plist key. 

  4. Apple Developer, “Information Property List: WKBackgroundModes”. Supported session-type strings: workout-processing, mindfulness, self-care, physical-therapy, alarm, underwater-depth

  5. Apple Developer, “Building a watchOS app” and the WatchKit testing guidance. Real-device runtime behavior is not reproducible in the watchOS simulator; the simulator does not enforce wrist-drop suspension. 

  6. Apple Developer, “WKExtendedRuntimeSessionInvalidationReason”. Enumeration cases: none, sessionInProgress, expired, resignedFrontmost, suppressedBySystem, error

Related Posts

HealthKit + SwiftUI on iOS 26: Authorization, Sample Types, and Cross-Platform Patterns

Real production patterns from Water (water tracking, HKQuantitySample) and Return (mindful sessions, HKCategorySample). …

17 min read

What SwiftUI Is Made Of

SwiftUI is a result-builder DSL on top of a value-typed View tree. Once the substrate is visible, AnyView, Group, and Vi…

17 min read

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 min read