Live Activities Are a State Machine, Not a Badge
Genre: shipped-code. The post documents the Live Activity I built into Return, the SwiftUI meditation timer my wife uses, my mom uses, and a few thousand strangers use.1 The patterns are the ones that survived production. The brutal-honesty footer says what I do not yet know.
The Live Activity in Return looks like a countdown number on the Lock Screen and on the Dynamic Island.2 It is not a number. It is a five-lifecycle-state machine with three external dismissal paths and one reentrant start path that has to defend itself against itself.
I shipped a v1 that treated the Live Activity as a badge. The “current time remaining” was data; the rest was decoration. That version had three bugs I caught in TestFlight and one I caught in production:
- Tapping start while the start was already in flight created a second activity that orphaned the first.
- The countdown rendered correctly on Dynamic Island, but the Lock Screen view hit
endTime <= Date()for paused timers and showed0:00until the user resumed. - The Live Activity stayed visible long after the user reset the timer because the dismissal policy was
.default, which Apple keeps visible for some time up to four hours. - (Production.) On Right-to-Left language locales (Arabic, Hebrew), the digits rendered backwards in the compact-trailing region of the Dynamic Island. Latin digits, RTL layout. The fix was one line.
Each of those was a state-machine bug. The countdown number was fine. The number is not the product. The product is the state.
The state machine below is what survived those bugs.
TL;DR
- The shipping
LiveActivityManagerexposes 5 transition methods (startActivity,updateActivity,showCycleComplete,showFinalCompletion,endActivity) plus 1 read (hasActiveActivity). The 224 production lines guard one specific hazard insidestartActivity: concurrent start calls plus cancellation checks across eachawaitboundary in that method.3 - The
ContentStatecarries 6 fields:endTime,currentCycle,totalCycles,isPaused,isCompleted,remainingSeconds. The first five are the state machine’s labels. The sixth (remainingSeconds) is a static-display fallback that ActivityKit’s livetimerIntervalcannot serve. - The dismissal policy decision is the real product call.
.immediatefor user reset,.after(Date().addingTimeInterval(3))for completion, never the system default. - The Dynamic Island compact-trailing region needs
.environment(\.layoutDirection, .leftToRight)on the timer text to keep Latin digits LTR under RTL system locales.
The State Machine
The shipped Live Activity has one idle state, three live states the user can observe, one terminal state, and one reentrant gate the developer must observe:
┌──────────────────────────────────────────────────────────────────┐
│ Lifecycle states │
├──────────────────────────────────────────────────────────────────┤
│ IDLE currentActivity == nil; no Live Activity present │
│ RUNNING isPaused=false, endTime > Date() │
│ PAUSED isPaused=true, remainingSeconds=N │
│ CYCLE_END isPaused=false, endTime <= Date(), isCompleted=false│
│ COMPLETE isCompleted=true (terminal; transitions to IDLE) │
└──────────────────────────────────────────────────────────────────┘
│
↓
┌──────────────────────────────────────────────────────────────────┐
│ Dismissal policies (Apple) │
├──────────────────────────────────────────────────────────────────┤
│ .immediate user reset │
│ .after(now + 3s) completion display window │
│ .default system decides; can stay up to 4 hours │
└──────────────────────────────────────────────────────────────────┘
Reentrancy gate inside startActivity():
isStartingActivity flag + cancellable startActivityTask
prevents two concurrent startActivity() calls from creating
two Live Activities for one timer. Cancellation checks across
each await keep the in-flight task safe to abort.
The render path checks isPaused first; that ordering is what keeps a paused timer from rendering as CYCLE_END when wall-clock time has crossed endTime.7
The state names are not labels on the number. The state names are the contract between LiveActivityManager (the app side, where my SwiftUI views live) and ReturnLiveActivity (the widget extension, where Apple’s process renders the surface).
The contract is TimerActivityAttributes.ContentState, all 6 fields:3
public struct ContentState: Codable, Hashable {
var endTime: Date
var currentCycle: Int
var totalCycles: Int?
var isPaused: Bool
var isCompleted: Bool = false
var remainingSeconds: Int = 0
}
Every state transition mutates this struct and asks ActivityKit to deliver it across process boundaries to the widget extension. The widget then re-renders. There is no shared memory. There is no callback. There is a Codable struct that crosses a process boundary on every transition.
That fact rules out anything I might want to do with closures, view models, observable objects, or computed properties. The state has to be expressible as serializable data. If it cannot be encoded, it cannot transition.
The Reentrant Start
Live Activities have a hard limit on concurrent activities and a soft limit on what happens if you call Activity.request twice in flight. The hard limit is well documented.4 The soft limit is “the second call may succeed and create an orphan.” The orphan is the Live Activity that is no longer associated with currentActivity in your manager. It survives. It has no path back into your code. It dismisses on its own staleness timer eventually. The user sees a duplicate timer until then.
The orphan was the v1 bug Return shipped. The fix is the reentrant gate plus a cancellable Task in LiveActivityManager.swift:3
private var isStartingActivity = false
private var startActivityTask: Task<Void, Never>?
func startActivity(...) {
#if os(iOS)
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
guard !isStartingActivity else { return }
isStartingActivity = true
startActivityTask?.cancel()
startActivityTask = Task {
defer {
isStartingActivity = false
startActivityTask = nil
}
guard !Task.isCancelled else { return }
await endActivity() // explicit cleanup of any prior state
guard !Task.isCancelled else { return }
// ... build attributes + contentState ...
do {
let activity = try Activity.request(...)
guard !Task.isCancelled else { return }
currentActivity = activity
} catch {
// log; flag clears via defer
}
}
#endif
}
Three things about that pattern that the docs do not call out:
The isStartingActivity flag is the active protection; startActivityTask?.cancel() is defensive cleanup. The flag short-circuits any second startActivity call while the first is in flight, so you do not actually race the public path. The cancel-then-replace dance still matters because the in-flight Task is async and can outlive a short-lived caller; the cancellation prevents a stale Task from continuing after the caller has moved on.
The guard !Task.isCancelled checks across each await boundary. Cancellation is cooperative in Swift. Even if cancel is called, the Task keeps running until it explicitly checks. Each await is an opportunity to check. Without the post-await checks, a cancelled Task keeps building activity state, calls Activity.request, and silently creates an orphan on success.
The defer clears the flag before the Task body completes. Without defer, an early return (from the cancellation check) leaves isStartingActivity = true permanently and the activity never starts again until app relaunch. The flag is a lock; the lock has to release on every exit path.
The pushType: nil argument. Return does not use APNs-pushed Live Activity updates. The app updates the activity locally via activity.update. If you need push-driven updates (delivery tracking, sports scores, real-time data), the type is pushType: .token and the contract is dramatically more complex.5 Local updates are simpler and they cover any timer / counter / single-app workflow.
The Pause Problem
ActivityKit ships a beautiful Text(timerInterval: Date()...endTime, countsDown: true) view that renders a live countdown without any update from the app.6 You set the end time, the system renders a live timer. No Timer.publish, no widget refresh, no battery drain.
That is fantastic when the timer is running. It is wrong when the timer is paused.
The timerInterval text counts toward endTime regardless of any “pause” signal in the state. There is no “frozen at 10:23” mode in Apple’s API. If you pass endTime = Date().addingTimeInterval(623) and the user pauses at the 10:23 mark, the timer text keeps counting down to zero in the widget. The state field says paused. The widget renders running.
The fix is to render two different views from the same state:7
if context.state.isPaused {
// static text
Text(formatTime(context.state.remainingSeconds))
.monospacedDigit()
} else if context.state.endTime > Date() {
// live countdown
Text(timerInterval: Date()...context.state.endTime, countsDown: true)
.monospacedDigit()
} else {
// post-end static
Text("0:00")
.monospacedDigit()
}
The two-track rendering is why the ContentState carries remainingSeconds as a separate field. It is redundant when the timer is running (the system computes it from endTime). It is the only source of truth when the timer is paused. The struct’s two halves serve two different rendering modes; the isPaused boolean selects between them.
The Dismissal Policies
activity.end(_:dismissalPolicy:) takes one of three ActivityUIDismissalPolicy values, and choosing wrong is what made my v1 stay on the user’s Lock Screen for what felt like an eternity after a reset:13
| Policy | When to use | What you get |
|---|---|---|
.immediate |
User reset, error, app backgrounded with no activity to track | Activity disappears now. No grace window |
.after(date) |
Completion display: “your meditation is complete” needs to be readable for a moment. The date must be within the four-hour window Apple allows | Activity shows the final state, then dismisses at date |
.default |
When you genuinely want Apple’s heuristics to decide | System keeps it visible “for some time” (Apple’s wording), up to four hours after end is called |
Return uses .after(Date().addingTimeInterval(3)) for the natural completion path:3
await activity.end(
.init(state: contentState, staleDate: nil),
dismissalPolicy: .after(Date().addingTimeInterval(3))
)
Three seconds is the time a user needs to glance at the Lock Screen, register that the timer ended, and feel the satisfaction of the checkmark. Less than three is jumpy. More than three feels like the activity does not know it is done.
For a user-triggered reset, the call is dismissalPolicy: .immediate. No window. The user already knows.
The wrong choice in v1 was .default. For a completed meditation timer the system kept the activity visible long enough that users thought the app had not registered the completion at all. Apple’s documentation says .default keeps the ended activity “visible for some time” up to four hours;13 the correct posture for a timer is to make the dismissal explicit.
The Dynamic Island Compact Region
The Dynamic Island has three rendering modes and you need all three even for a simple timer:2
- Compact (default Dynamic Island shape): leading icon + trailing timer
- Minimal (when another Live Activity competes for the same Dynamic Island): leading icon only
- Expanded (long-press): four named regions (
leading,trailing,center,bottom)
The pattern that earned its place in Return is to make the expanded view nearly identical to compact:8
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image("AppIconSmall")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
DynamicIslandExpandedRegion(.trailing) {
TimerText(...)
}
DynamicIslandExpandedRegion(.center) { EmptyView() }
DynamicIslandExpandedRegion(.bottom) { EmptyView() }
} compactLeading: {
Image("AppIconSmall")...
} compactTrailing: {
TimerText(...)
} minimal: {
Image("AppIconSmall")...
}
Most Live Activity tutorials lean into the expanded view as the “real” design, with rich content in the bottom region. For a meditation timer, expansion is dead weight. The user opens the expanded view by long-pressing, and the long-press already gives them the haptic feedback that something happened. Adding content makes the expansion say something the user did not ask. Empty regions in expanded mode are not a failure of the design; they are the design.
The RTL Bug
The production bug. Arabic and Hebrew users on iOS reported that the Dynamic Island compact-trailing timer rendered the digits backwards. The Latin numeral string 5:23 was rendering as 32:5 because the compact-trailing layout direction inherited the system locale’s RTL setting.
SwiftUI inherits the system layout direction inside the widget process, so the Dynamic Island timer text picked up RTL when the user’s phone was set to Arabic or Hebrew. Latin numerals ought to render LTR even inside an otherwise RTL UI. The fix is to pin layout direction on the numeric text views:7
.environment(\.layoutDirection, .leftToRight)
The override goes on the numeric Text views inside TimerText (Dynamic Island compact / expanded) and inside the Lock Screen view, not the whole view. Latin digits read left-to-right regardless of the user’s system locale; cycle labels like “Cycle 2 of 3” stay localized so they follow the system layout direction.
The bug does not surface in domestic-locale TestFlight. It surfaces the moment a real RTL user opens the timer. The lesson: ship the LTR-pinned environment override on every Latin-digit text view in any Live Activity that might run in RTL locales.
The Localization Story
TimerActivityAttributes carries a languageCode: String field set by the app on activity creation:9
let attributes = TimerActivityAttributes(
timerDuration: duration,
languageCode: settings.appLanguage // app's selected language, not system's
)
The widget extension reads this to render localized strings:
private var locale: Locale {
let code = context.attributes.languageCode
return code.isEmpty ? .current : Locale(identifier: code)
}
private func localized(_ key: String.LocalizationValue) -> String {
String(localized: key, locale: locale)
}
Why the app passes its own language code rather than letting the widget read Locale.current: the widget extension runs in its own process. Its Locale.current is the system locale, not the app’s selected locale. If a user has set Return to Korean while their iPhone is in English, the widget would speak English without this override. The app’s language preference travels in the activity attributes; the widget honors it.
Localizable.xcstrings lives in the widget target alongside the app’s, but they are separate files. Strings used in the widget have to exist in ReturnWidgets/Localizable.xcstrings even if the same string exists in Return/Localizable.xcstrings. Forgetting this means the widget falls back to the development language while the app speaks Korean.
What I Would Build Differently
Make ContentState smaller. Six fields is too many. The redundancy between endTime and remainingSeconds is the price of working around the no-pause-mode in timerInterval. If I were starting over, I would carry a single displayMode enum (running, paused(remainingSeconds: Int), cycleEnd, complete) and let the rendering code dispatch on the case. Six fields is harder to keep correctly mutated across five transition methods than four cases is.
Add interactive Live Activity buttons (iOS 17+). Return does not currently expose pause/resume controls in the Dynamic Island. The user has to open the app to pause. iOS 17 added Button(intent:) for App Intents inside Live Activities.10 An interactive pause control is the obvious extension and the next thing I will ship for Return.
Push-update Live Activities for cross-device timer sync. Return syncs sessions across iPhone, iPad, Watch, and Apple TV via NSUbiquitousKeyValueStore (covered in Five Apple Platforms, Three Shared Files). Today the activity is started locally from the iPhone or iPad app and updated locally. A user starting a timer on Apple Watch could ideally see the Live Activity reflect that on iPhone in real time. APNs push to the Live Activity is the path.5 Have not built it.
When Not To Use Live Activities
One-shot transient state. A “saved!” toast does not deserve a Live Activity. The system has a banner. Use it.
Frequently-changing data without a timer dimension. Live Activities work best for things with a clear temporal anchor (a timer, a delivery ETA, a game clock, a phone call duration). Stock tickers and sports scores work because they have a session window. A general-purpose dashboard does not.
Apps without a Lock Screen / standby use case. Live Activities take real engineering investment (target setup, ContentState design, dismissal policy decisions, RTL handling, localization plumbing). Apps the user opens directly without ever consulting the Lock Screen during use are not the right shape. A photo editor does not need one. A workout tracker does.
On non-iOS surfaces, with caveats. Return’s LiveActivityManager ships its implementation behind #if os(iOS) because the timer is started from the iPhone or iPad app. ActivityKit itself describes Lock Screen banner, Dynamic Island, Apple Watch Smart Stack, Mac, and CarPlay as presentation surfaces; iOS 26 expanded several of those.4 watchOS still has its own complications API for full-screen rendering. macOS has menu bar apps. iPadOS supports Live Activities since iPadOS 17 with no Dynamic Island region. Return’s manager has 8 #if os(iOS) guards across one 224-line file.
What The Pattern Means For Apps Shipping On iOS 26+
Two takeaways.
-
Treat the Live Activity as a state machine, not a number. The state machine has clear states, clear transitions, and clear dismissal rules. The number on the screen is one rendering of one state. Get the states right first.
-
The reentrancy guard is the bug you have not hit yet. Every Live Activity manager I have seen in the wild that does not implement
isStartingActivity+ cancellable Task has shipped at least one orphan-activity bug. The guard is 6 lines. Write it once.
Pair this post with my prior writeups for the same family of apps: typed App Intents for Apple Intelligence; MCP servers for cross-LLM agents; Liquid Glass patterns for the visual layer; multi-platform shipping for cross-device reach. Live Activities are the iOS-Lock-Screen-and-Dynamic-Island layer of the same stack. The full set lives at the Apple Ecosystem Series hub. For the broader iOS-with-AI-agents context, see the iOS Agent Development guide.
FAQ
What’s the difference between Live Activities and WidgetKit widgets?
WidgetKit widgets render at intervals defined by TimelineProvider; the system decides when to refresh and the widget re-renders from a static timeline.11 Live Activities render in response to specific app-driven activity.update(...) calls and live for the duration of the underlying activity (a timer, a delivery, a workout). Both ship in the widget-extension target; the difference is the trigger model.
Do Live Activities work on iPad?
Yes, in iPadOS 17+. The Lock Screen banner is the primary rendering surface; iPad does not have a Dynamic Island. The same ActivityConfiguration code works; just expect the Dynamic Island regions to never render on iPad.
Can a Live Activity outlive my app process?
Yes. Once Activity.request succeeds, ActivityKit owns the activity. The app process can be terminated by the system; the activity continues rendering on the Lock Screen and Dynamic Island until you explicitly end it (or until the system staleness rules dismiss it). Explicit endActivity() calls matter for that reason; without an explicit end on app reset, the activity outlives the timer.
Why does the post not cover push-updated Live Activities?
I have not shipped push-updated Live Activities in Return. Per the genre rule for this cluster: shipped-code posts only document what the production code does. Push-updates are listed in “What I Would Build Differently”; a future post will cover them after I ship them.
What’s the actual file layout for Live Activities in a SwiftUI app?
- In the main app target:
LiveActivityManager.swift(manages activity lifecycle),TimerActivityAttributes.swift(theActivityAttributesstruct shared with the widget; both targets compile this file). - In a widget extension target:
ReturnLiveActivity.swift(theWidgetconformance withActivityConfigurationbody),ReturnWidgetsBundle.swift(the@main WidgetBundle). - Configuration:
Info.plistwithNSSupportsLiveActivities = YESin the app target.
The widget extension target needs ActivityKit and WidgetKit imports. TimerActivityAttributes is the only file shared across both targets; everything else is target-isolated.
The Live Activity is not a number on the Lock Screen. It is a state machine that crosses a process boundary every transition. Get the states right, guard the reentrancy, choose the dismissal policy on purpose, and pin the layout direction. The number takes care of itself.
References
-
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. Live Activities ship on the iOS target only. ↩
-
Apple Developer, “ActivityKit framework”. Lock Screen banner, Dynamic Island compact / minimal / expanded modes, activity lifecycle. Available iOS 16.1+; Dynamic Island available iPhone 14 Pro and later. ↩↩
-
Production code in
Return/Return/LiveActivityManager.swift(224 lines, 8#if os(iOS)blocks) andReturn/Return/TimerActivityAttributes.swift(43 lines). Shared between the app target and the widget extension target via target membership. ↩↩↩↩↩ -
Apple Developer, “Displaying live data with Live Activities”. Concurrency limits, supported platforms (iOS 16.1+, iPadOS 17+),
NSSupportsLiveActivitiesInfo.plist key. ↩↩ -
Apple Developer, “Updating and ending your Live Activity with ActivityKit push notifications”. The
pushType: .tokenpath requires a separate APNs auth key, server-side push token registration, and a different update protocol from localactivity.update(...)calls. ↩↩ -
Apple Developer, “Text(timerInterval:pauseTime:countsDown:showsHours:)”. Live system-rendered countdown timer; renders without app updates while the activity is running. ↩
-
Production code in
Return/ReturnWidgets/ReturnLiveActivity.swift(232 lines). The widget extension’sWidgetconformance withActivityConfiguration<TimerActivityAttributes>body. TheTimerTextview at lines 61-102 handles the paused / running / post-end three-state rendering. ↩↩↩↩ -
Apple Developer, “DynamicIsland”. The four named expanded regions (
leading,trailing,center,bottom) plus three compact-mode views (compactLeading,compactTrailing,minimal). ↩ -
The widget extension runs in its own process and inherits the system locale, not the app’s selected locale. Apps that support in-app language switching (Return supports 27 languages) must pass the language code through
ActivityAttributesso the widget can render in the user’s chosen language. Pattern:Locale(identifier: context.attributes.languageCode)rather thanLocale.current. ↩ -
Apple Developer, “Button(intent:)”. Available in widget and Live Activity views from iOS 17+. Bridges App Intents into Lock Screen / Dynamic Island controls without requiring app foregrounding. ↩
-
Apple Developer, “TimelineProvider”. The widget refresh model that predates Live Activities; pre-computed entries with system-managed reload windows. ↩
-
Production code in
Return/ReturnWidgets/ReturnWidgetsBundle.swift(16 lines). The@main WidgetBundlethat registersReturnLiveActivityas the widget extension’s only widget. Required pattern for widget extensions; the bundle is what the system loads. ↩ -
Apple Developer, “ActivityUIDismissalPolicy”. Three cases:
.default,.immediate,.after(_:). Apple states.defaultkeeps an ended Live Activity visible “for some time” up to four hours, and.after(_:)accepts a date within the same four-hour window. ↩↩