Maintenance Log
Real problems I ran into during development, how I traced the root cause,
and how I resolved them - written up in a maintenance-log format.
Not everything got fixed, and structural limitations are labeled honestly as limitations.
- FINDING
Identifying the problem
Even while mirroring, the iPhone and Watch were each running their own LocationService and RunningCenter independently. Starting from the Watch still caused a delay while the iPhone acquired its own GPS lock.
- ACTION
Changing direction
Extended the existing startOrigin property to also govern whether a device tracks location. Only the leading device turns on GPS and sends the computed results to the other; the mirroring device just receives and displays.
- DISCOVERY
Implementing both directions
sendFlightData() only existed on the iOS side, so data could only flow iPhone → Watch. In a Watch-led mirroring session, there was no path at all for the Watch's computed data to reach the iPhone - a hidden gap.
- RESULT
Closing the gap
Bundling elapsedTime into the 3-second-throttled FlightData payload would let drift accumulate, so it's synced separately every 1 second via its own message (sendElapsedTime()). All four scenarios - iPhone-only, Watch-only, and bidirectional mirroring - now resolve to a single startOrigin-based flow.
- FINDING
Discovery
Stack trace tracing pointed to a crash inside session(_:activationDidCompleteWith:error:).
- ROOT CAUSE
Root cause
The class itself was declared nonisolated, but without marking the delegate methods inside the extension explicitly, Xcode 26's SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor default re-inferred them as @MainActor.
- FINDING
Recurrence
Even after the first fix, the same crash pattern reappeared - only on real devices - inside didReceiveUserInfo.
- ACTION
Full audit
Audited every WCSessionDelegate callback across both the +iOS and +watchOS extensions and added the missing nonisolated everywhere it was needed.
- APPROACH
First implementation
Used Timer.publish().connect() to control the start point directly, choosing it over autoconnect() with pause support in mind.
- FINDING
Restart failure
The timer wouldn't run again after a restart. connect() is a one-time connection - once a Publisher is cancel()'d, it can't be reconnected.
- ACTION
Switching to autoconnect
Switched to subscribing fresh with autoconnect() + sink every time start() is called, storing it in a Set<AnyCancellable> and clearing it with removeAll() on stop().
- RESULT
Preventing duplicate subscriptions
Found that tapping start() repeatedly stacked up subscriptions, making seconds jump by 2 or 3 at once. Clearing any existing subscription right when start() runs fixed it for good.
- FINDING
Discovery
Instruments' Swift Concurrency profiler showed that a new AsyncStream and a new Task were being spun up on every single location update.
- ACTION
Promoting to a property
Promoted the continuation to an Actor property. The stream is now opened only once, and processLocation() yields directly through the stored continuation.
- FINDING
Sendable conflict
Setting continuation = nil directly inside onTermination threw 'Actor-isolated property can not be mutated from a Sendable closure' - the closure runs on an arbitrary thread, so it can't touch an Actor-protected property directly.
- RESULT
Splitting init from a task
Created an Actor-isolated method (clearContinuation()) and wrapped it in Task { await ... }. Later split this out into startStream(), called from the View's .task, to clean up the flow further.
- FINDING
Identifying the problem
Only the normal flow (tapping a button) handled state cleanup - abnormal exits like tapping the tab bar or turning the Watch crown weren't covered at all, something I only caught through real-device testing.
- FINDING
Choosing the right signal
Tried using an isRunning flag, but on the iPhone during mirroring, start() is never called, so the flag could read false even though a session was still alive. Switched the check to HealthKitService.shared.session != nil instead.
- ACTION
Flag pattern
Button actions now set a flag like didNavigateToTouchdown to true first, and .onDisappear interprets the absence of that flag as an 'abnormal exit.' Applied the same pattern consistently across five Views.
- RESULT
Handling the exception
FlightSummaryView is also reused for browsing the Logbook, so it's distinguished with a selectedFlight == nil condition. On Watch, only WatchSummaryView is set to always clean up unconditionally.
- FINDING
First attempt
Tried treating a session as a zombie if 5 seconds had passed since startDate, but couldn't verify it - force-quitting via the debugger immediately severed the connection, making it impossible to check the logs.
- DISCOVERY
Pinning down the cause
Only after switching to os_log / Console.app and resolving a privacy-masking issue did the real cause come into focus: retrieveRemoteSession was re-detecting a session that was already alive on relaunch.
- ACTION
Flag-based detection
A UserDefaults flag (wasZombieSuspected) detected the condition accurately, but calling .end() on it triggered a side effect - it cascaded into ending the Watch session too.
- FINDING
Exploring alternatives
Tried comparing appLaunchTime instead. Ignoring a detected zombie blocked new mirroring sessions, while calling end() reintroduced the earlier side effect - a genuine dilemma.
- RESULT
Confirming the structural limit
Concluded that HKWorkoutSession is a healthd (system daemon) level resource that app code simply can't fully control. Rolled back all related code and kept only the logging infrastructure.
Concluded that HKWorkoutSession is a healthd (system daemon) level resource that app code can't fully control. os_log/Console.app tracing pinpointed the exact cause, but with no fix possible without side effects, this was documented as a known limitation for v1.0.
- FINDING
The symptom moved
The issues seen in phone-led mirroring (Watch display not updating, stop not syncing) disappeared after a rebuild with zero code changes. In their place, Watch-led mirroring started showing a new symptom: location never acquired at all.
- ROOT CAUSE
Async assignment vs. sync check
The startOrigin = .local assignment inside updatePhase(.cruise) sits inside a Task {}, making it async. The very next line calls start(), which checks that value once, synchronously. If the check runs before the assignment lands, GPS never turns on for the rest of that run.
- DISCOVERY
Why the iPhone was unaffected
Same call order, yet the iPhone never hit this. Adding the pre-flight check to TakeoffView had it call prepareTracking() ahead of time, so GPS was already running by the time start() ran - the race never had a chance to matter. The Watch had no such pre-step, so it was fully exposed.
- ACTION
Removing the race entirely
Brought the same prepareTracking()/stopTracking() pattern to the Watch. Split the GPS-start logic out of start() completely, so start() no longer reads startOrigin at all.
- RESULT
Unified platform architecture
Added a didStartFlight flag to distinguish a normal ROTATE entry from bailing out mid-countdown. iOS and Watch now share the same architecture, which has made it easier to read the two platforms' code side by side since.
- FINDING
Screen and storage disagreed
The live PFD pace calculation already had an isFinite guard, so the screen safely showed --'--" whenever the math broke down. saveRunningData(), which persists to SwiftData, had no such guard.
- ROOT CAUSE
inf propagating downstream
Dividing by zero distance returns inf in Swift - no crash, just a silently invalid Double. That inf got saved as-is, and a single inf mixed into the reduce that computes the monthly average was enough to corrupt the entire sum, wiping out that month's average.
- ACTION
Guarding both write and read paths
Added an isFinite guard at save time so unrepresentable values are stored as 0, plus a second isFinite filter at aggregation time as a backstop. WatchPFDView.swift had the identical calculation, so the same guard went there too.
- FINDING
A follow-up bug
Found a separate issue where stopping a run seconds after starting produced a wildly spiked pace. Not inf this time - just an unrealistically large finite value from dividing a tiny distance by a tiny time, which slipped right past the isFinite guard.
- RESULT
Thresholds and cleanup
Added a minimum valid distance (50m) and a realistic pace ceiling (30 min/km). Deleted the 14 already-corrupted records with a one-off cleanup script. The lesson: 'invisible on screen' isn't the same as 'safe' - what actually gets persisted needs its own guarantee.
- FINDING
The pattern
Re-auditing all four mirroring combinations (app-led/Watch-led x app-ends/Watch-ends) in code, more than half of every bug found so far traced back to exactly one combination: Watch leading while iPhone had to mirror it live.
- DISCOVERY
What the crossed GPS tracking actually was
WatchPFDView's doc comment claimed '.onDisappear cleans up state,' but there was no such handler in the code. Leaving via the Digital Crown never stopped GPS tracking, so an orphaned session kept running and overlapped with the next run.
- DISCOVERY
Same root cause behind the Dynamic Island
updateCruise(), which refreshes the Live Activity, was only ever called from iPhone's own GPS stream - never from the flightData messages received from Watch. When Watch led, the Live Activity was structurally stuck at its start screen.
- ACTION
The trade-off
Instead of full bidirectional mirroring, kept app-led mirroring (still needed for Watch sensor data) and branched on startOrigin so Watch never even attempts mirroring when it leads. On stop, it just falls back to the existing standalone-run path, delivering the record to iPhone's Logbook.
Accepted the reality that most people aren't looking at their phone while a Watch-led run is happening. Decided iPhone didn't need to mirror it live, and deliberately narrowed the scope to match actual usage instead of chasing full bidirectional mirroring. This walks back one of the four scenarios that entry #01 (mirroring redesign) had originally built out.
- FINDING
The real-device symptom
Watch ended a run while mirroring, but the iPhone was still alive. Resuming on Watch right after, Watch and iPhone both kept collecting their own location data at the same time, and ending the Watch run again logged the same run three separate times.
- ROOT CAUSE
No shared identifier for "this is the same run"
Each device just saved whatever run ended on its own side, with no way to check whether the other device was talking about the same run. Even a slightly delayed stop signal was enough for a new run and a stale session to get tangled together.
- ACTION
Introduced a shared session ID
The device that starts the run issues a UUID and holds it as runSessionID, sending it along with every flightData message and the final save message. At save time, that same value becomes SwiftDataFlight.id, so a record that already exists under that id is never saved again.
- ACTION
Guarded the receiving side too
Once Watch starts its own new run (startOrigin == .local), it now ignores any stale flightData still arriving from iPhone. The same guard was mirrored on iPhone's side for the opposite direction.
- DISCOVERY
Cleaned up iPhone's native mirroring receiver too
While digging into this, found that HealthKitService+iOS.swift's retrieveRemoteSession() wasn't actually used for heart-rate delivery or for saving to Apple Health - either way already went through a different path. Keeping it registered only risked breaking the save guard or pushing PFDView onto the navigation stack twice, so the registration was disabled.
Entry #09 already removed Watch-led mirroring itself, but the code that let iPhone receive Watch's mirrored session back was still sitting there. Now the start side is decided by iPhone alone, full stop, and the stop side confirms it's the same run via the shared session ID.
- FINDING
The real-device symptom
When iPhone led mirroring by launching the Watch app through startWatchApp, ending the run on Watch never ended the run on iPhone at all.
- ROOT CAUSE
Quietly depending on the code entry #10 had just disabled
WatchViewModel decided whether to tell iPhone about its own stop based on runningMode == .mirrored, but runningMode only flips to .mirrored when startMirroringToCompanionDevice() succeeds. Entry #10 had disabled retrieveRemoteSession() - the listener that handshake needs on iPhone's side - so that call could now fail, leaving runningMode stuck at .standalone and sendStopSignal() never firing.
- ACTION
Swapped the condition
Switched the check from runningMode to startOrigin != .local. startOrigin gets set unconditionally in AppDelegate.handle() regardless of whether that handshake succeeds, and stopWorkout() never touches it, so it's still there by the time the stop event gets handled.
A side effect of disabling retrieveRemoteSession() in entry #10. A reminder that removing code means checking what else was quietly leaning on it too - this time, a completely different function's branching logic.
- FINDING
Found while splitting off a hotfix
Found this while rushing an unmerged heart-rate branch out as a standalone v1.0 hotfix. RunningCenter.reset() was called defensively not just on screen transitions but inside start() too - and that call was wiping modeAData (the mission target) every single time.
- ROOT CAUSE
One function, two unrelated jobs
The defensive reset() in start() only existed to stop elapsedTime/flightData from carrying over stale values - it was never meant to touch modeAData. But because both lived in the same function, every screen transition and every restart guard wiped the mission target too.
- ACTION
Split the responsibility
Pulled target-clearing out into its own clearModeAData(). reset() no longer touches modeAData at all, and only RunViewModel/WatchViewModel's resetState() calls clearModeAData() explicitly.
When two unrelated jobs - "cleanup for a screen transition" and "defensive reset on restart" - live in the same function, a change meant for one keeps leaking into the other. Splitting responsibility by function was the whole fix.
- FINDING
A blind spot in the test method
Added target/heartRate fields to SwiftDataAlert for heart-rate-based GPWS, but every test cycle so far had been delete-and-reinstall - meaning the actual migration path had never once been exercised.
- ROOT CAUSE
init defaults are invisible to migration
The new fields' defaults lived only in the init() parameters. SwiftData's automatic lightweight migration fills in new fields on existing records by reading the property declaration's default value - init parameter defaults aren't visible to it at all.
- ACTION
Move the default onto the declaration
Added the default value directly at the property declaration. Further research turned up cases where crashes still happen even with that in place - Apple's own forums call this "expected behavior." A fully safe path needs a multi-stage VersionedSchema migration: optional first, then promoted to required.
Delete-and-reinstall testing never exercises the migration path at all. Any schema change now gets checked at least once via an in-place update from the prior version.
- FINDING
Bug 1 - silently failing from the second backup on
The backup button created records fine in the CloudKit Dashboard the first time, but the second press reported "0 backed up."
- ROOT CAUSE
A change-tag mismatch buried inside the result
modifyRecords(saving:deleting:)'s default save policy (.ifServerRecordUnchanged) compares against the server record's change tag, but a freshly-created local record has none - so it reads as a conflict and fails. That failure never throws; it's buried inside the per-record Result, so nothing looked wrong on the surface.
- ACTION
Switched to unconditional overwrite
Switched to savePolicy: .changedKeys so the local state always wins.
- FINDING
Bug 2 - the Dashboard couldn't query it
Backups succeeded, but an unconditional query in the CloudKit Dashboard threw "Field 'recordName' is not marked queryable."
- ROOT CAUSE
recordName has no index by default
CKQuery needs a Queryable index on any field used in a condition or sort, and recordName doesn't get one by default. An unconditional query (NSPredicate(value: true)) was internally falling back to recordName and hitting that wall.
- RESULT
Changed the query and added the index
Rewrote the query to filter on the date field instead, sidestepping recordName entirely, and added a QUERYABLE index on recordName from Schema > Indexes in the Dashboard as well.
An API that buries failures inside a result value instead of throwing means "success-shaped failures" need the return value itself checked, not just the absence of an error. It's also easy to forget that the Dashboard's own query and the app's code can hit CloudKit very differently.
- FINDING
The real-device symptom
Running with music on, the km-split announcement was inaudible entirely. SpeechAnnouncerService configures AVAudioSession with .duckOthers so music ducks only while announcing, but that activation call only fired once at run start. Once a music app grabbed the session mid-run and interrupted it, nothing ever reactivated it.
- ACTION
First fix, then the opposite problem
Reactivating the session right before every speak() call fixed that, but a second real-device test showed music staying ducked for the entire run instead of just a few seconds. A leftover activation call in start() was the cause. Ducking follows session activation, not the speak() call itself, so that call was removed and an AVSpeechSynthesizerDelegate callback (didFinish/didCancel) now deactivates the session the instant speech ends.
- DISCOVERY
Audio sessions don't cross devices
During a watch-only run with music playing on iPhone, the TTS was never audible at all. AirPods stay bluetooth-paired to both devices, but only one device owns the actual audio stream at a time. AVAudioSession is a fully separate instance per device, so nothing the Watch configures can reach iPhone's audio.
- ACTION
1.4.1 - the device holding the earphones reads it
The mirroring half turned out to be solvable in code. The first attempt let users pick the device in Settings, but AirPods move between the two devices mid-run, so the setting asked users something they could not know. Now the iPhone checks its own currentRoute: if the earphones are on its side it reads the split itself, otherwise it hands off to the Watch, and it reads the split itself if that handoff fails. An early version matched only Bluetooth ports and missed wired earphones (.headphones) entirely, so the check was inverted to ask whether output is leaving this device's own speaker rather than enumerating port types.
- FINDING
Nike Run Club hits the same wall
Community reports showed Nike Run Club has the identical limitation, and the only fix they offer is playing music from the Watch itself. This wasn't our app's bug. It's a structural limit across the whole Apple ecosystem.
Fixed both on-device ducking bugs, and 1.4.1 solved which device reads the split while mirroring. What remains is a watch-only run while music plays on the iPhone. The system decides who owns the audio stream, so a Watch app has no way to touch the iPhone's session. Added a note in the app's announcements screen instead, asking users to play music from the Watch itself. Not every problem gets solved by code. Sometimes an honest heads-up is the actual answer.
- FINDING
A TODO already sitting in the code
Turning the crown from the 100m minimum kept landing on awkward values. A "// needs fixing" comment was already sitting right there in the code.
- ROOT CAUSE
The starting point wasn't a multiple of the step
The crown binding started at 0.1 (100m) and added 0.5 each turn. Since 100m isn't a multiple of 0.5, every value after it inherited that same offset.
- ACTION
Switched to indexing a step array
Changed the crown to drive an index into a precomputed distanceSteps array instead of the distance itself. 100m sits as a one-off exception at the front, followed by a clean 0.5km grid with half/full marathon (21.1km/42.2km) slotted in. Preset buttons (3K/5K/10K/HALF/FULL) were switched to look up their index too.
A common but easy-to-miss arithmetic bug - when the starting point and the step size aren't multiples of each other, that mismatch just keeps compounding.
- FINDING
Reproduced right after release
Shortly after 1.3 shipped, slowing down in response to an OVERSPEED warning correctly switched to SINK RATE, but speeding back up never returned it to NORMAL. The deviation number kept climbing to 300 seconds, and the Watch app eventually force-quit.
- ROOT CAUSE
Stationary detection and pace calculation were watching different things
Samples with negative GPS confidence (speedAccuracy) were reliably skipped by stationary detection (isStationary), but the speed smoothing used for pace calculation kept updating unconditionally outside that check. The moment you slow down hard in response to OVERSPEED is exactly when GPS confidence tends to go negative, so stationary detection never confirmed while the smoothed speed kept getting pulled toward zero.
- DISCOVERY
Cross-checked with heart-rate mode
Heart-rate-based GPWS doesn't go through this smoothing calculation at all - it reads live heart rate directly - and it had no problem under the same conditions. That confirmed the bug lived specifically in the pace calculation path.
- ACTION
Fix
Moved the smoothing update inside the same speedAccuracy check as stationary detection. An untrustworthy speed sample now leaves both stationary detection and pace calculation exactly where they were.
- FINDING
A second bug found while verifying the fix
While confirming this fix, a separate issue turned up: SINK RATE/OVERSPEED haptics kept buzzing the whole time you were resting. The existing logic that froze GPWS while stationary meant that if the state right before you stopped happened to be a warning, that warning stayed frozen, keeping the alert screen and its repeating haptic loop running.
- RESULT
Reset instead of freeze
Changed it so pausing resets GPWS to normal instead of freezing it. New false alerts from a resting heart rate are still suppressed, and any warning active right before stopping now clears the instant you stop. Shipped as 1.3.1.
This bug wasn't introduced all at once, it came from two separate changes made while preparing v1.3 overlapping. First, stationary detection was reworked from scratch around raw speed. Later, for a completely unrelated reason (suppressing false alerts from a resting heart rate), freeze-while-stationary logic was added on top. Each change was reasonable on its own; nobody realized at the time that the rule in stationary detection could break the assumption the later freeze logic depended on. It was a bug that only showed up in the relationship between two changes, not in either one read alone.
- FINDING
A comment that was not true
Two entries started at the same second, both sourced from RunWay, differing only in device. A code comment said saving to Apple Health happened only through the Watch builder. It did not. iPhone also attaches a builder to its session and begins collecting, and leaving that builder unfinished at the end lets the system close it out and save whatever it gathered.
- ACTION
Blocking one side
During mirroring the iPhone builder is discarded. Discarding unconditionally would leave nobody saving when running without a watch, so it only applies while mirroring.
- DISCOVERY
It split by who ended the run
On device, ending from the watch worked, but ending from the phone left nothing in Health at all. Ending on the watch goes through the HealthKit delegate, which finishes the workout. Arriving as a stop signal from the phone does not touch that delegate. The phone discarded its own builder and the watch never finished, so nobody saved.
- RESULT
Deciding the rule first
Settling on "the device with the sensors saves" made both fixes obvious. The watch now finishes on the remote stop path too, and the phone checks mirroring before clearing that state rather than after. finishWatchWorkout() also ends the session before ending collection, since the remote path arrives with the session still running.
At first this looked like reducing two savers to one. It was really about deciding which device owns the save. Without that decision, blocking one side meant nobody saved on the blocked path and both saved on the other. Removing a duplicate and deciding an owner were different jobs.
- FINDING
Someone else's symptom
Reported by a user on 1.3.1 and not reproducible on my own device. Tracing every path that clears the watch navigation left exactly one, and the user had pressed nothing, so something was ending the run on their behalf.
- ACTION
Two wrong hypotheses
First guess was a stop signal from the previous run queued and delivered late. A reproduction recipe went out and nothing reproduced; the recipe turned out to sidestep the very condition it was testing. Second guess was the splash screen withholding the NavigationStack for 1.5 seconds, which felt certain until a follow-up detail arrived: fine after a full quit, broken only from the background. Exactly backwards.
- DISCOVERY
They never viewed the summary
The third detail was the answer. Ending a run on the watch shows a landing screen, and leaving it without tapping summary means onDisappear never fires on backgrounding, so the teardown never runs. When the next run starts, PFD is pushed over that leftover screen, and the covered screen's onDisappear finally fires and tears down the run that just began.
- RESULT
Telling covered from left
The teardown now skips while a workout session is running. Leaving normally still passes, since the session has already ended by then. A new run also replaces the navigation path instead of stacking on the previous one. Everything written for the two wrong hypotheses was reverted.
onDisappear cannot tell "the user left" from "another screen covered this one". The larger lesson sits elsewhere. Both wrong hypotheses came from reading code; the right one arrived only after the reporter said they had not viewed the summary. Told the repro failed, I questioned the recipe instead of the hypothesis. For a bug someone else hit, no amount of reading fills in the conditions. Only they know them.
- FINDING
One case out of four
Ending from the app, running with the watch off, and watch-only runs all saved. Only ending from the watch failed, which meant the save function itself was fine. But all four early returns in it were silent, so there was no way to tell which one had fired.
- ACTION
Printing the order
Each early return got a reason, collected in order and shown together, on screen as well since this had to be checked outdoors. Ten lines came back, and two looked wrong: the duplicate guard read as off both times, and the same run reported 0m on one pass and 264m on another.
- DISCOVERY
There were two view models
A @State initial value on an App struct is rebuilt every time SwiftUI re-creates the struct, and only the first is kept. The discarded ones had already subscribed in init(), and a Task holding self strongly while awaiting a stream that never ends kept them alive. The duplicate guard is per-instance, so it never fired, and the ghost running first cleared shared state that the real instance then needed.
- RESULT
Checking each iteration
Both Tasks became weak and check whether the object is still alive on every iteration. Discarded instances now deallocate, and their subscriptions end with them. Rolling back to the 1.3.1 commit and repeating the test failed the same way, confirming this was not introduced by the current work.
Only one save path goes through the subscription, which is why the other three looked fine. The ghost had nowhere to show itself there. It was also receiving every other subscription from init(), not just stop signals; the failed save was simply the most visible symptom. That a @State initial value gets built more than once was known. That a discarded copy keeps working once subscriptions are involved was not.
- FINDING
No condition would hold
It was intermittent, and trying again always worked. It only showed up on long runs, yet one failed at 40 minutes and another was fine at 114, so length drew no line either. The worse part was that the iPhone had nothing to show. With no signal arriving, nothing happens at all, so there was no way to tell a signal never sent from one lost in transit from one received and discarded.
- ACTION
Build a way to read it first
Every step between the button and the signal got a line of its own. But the watch simulator does not persist what an app writes to the system log: six hours of it held zero lines from our app while system apps logged normally. The symptom appeared in the simulator and the log could only be read on a real device, so the place it reproduced and the place it could be read were different places. Writing the same lines to a file inside the app's own folder got around it.
- DISCOVERY
Measure a healthy stop first
A failed trace says nothing if you do not know how far it is supposed to go, so a successful stop was captured end to end first. Ten seconds from the button to the iPhone reaching home, six of them inside one call: the watch saving its workout to HealthKit. With that baseline, the missing line in a failed trace was obvious.
- ROOT CAUSE
The save never came back
On the day it reproduced, the trace stopped at "finishing". The system log showed collection ending in 3.5 seconds and the save that follows never completing. Nine minutes later it still had not, and the iPhone had recorded nine minutes and ten seconds more than the run actually lasted. The stop signal was published only after that save returned.
- ACTION
Turn the two around
The publish moved ahead of the save. The iPhone does not need the watch's HealthKit write to finish in order to end its own run. Nothing was removed; the save still runs, just behind the signal instead of in front of it.
- RESULT
Ten seconds down to three
On a run of the same length, button to home is now 3.2 seconds. On a six-hour-forty-one-minute run the save took 26 seconds and the iPhone was home in 4.2, twenty-four seconds before the save finished. A safety net landed with it: ten seconds of silence from the watch shows that the app is waiting, and fifteen asks whether to end the run here.
What changed was the order, not the save. Why the watch's save stalls on some runs and not others is still unknown. It is not length and it is not volume: the six-hour run wrote far more and finished, while the stalled one wrote to the database faster. The fix did not depend on that answer, though. The iPhone never had a reason to wait on the watch's save in the first place. Explaining a fault completely and removing what the user lives through are not always the same piece of work.