Skip to content

How Musklr Keeps Your Apple Watch and iPhone in Sync During a Workout

A Musklr engineering devlog on keeping the Apple Watch and iPhone in sync during a workout.

This is what logging a set looks like when your phone stays in your pocket. Dial the weight in with the crown, tap once to log it, keep lifting. For that to feel instant, the watch and the phone have to agree on the truth the whole time. Here is how we made them agree, and the five sync bugs we had to kill first.

Logging a leg press from the wrist in Musklr, no phone in hand.

How the sync actually works

The design is deliberately one-sided. The iPhone owns every piece of workout state. It builds a WorkoutSnapshot, one struct that carries the complete display state of the workout, and that snapshot is the single source of truth. The Apple Watch is a read-only display. It renders the latest snapshot and nothing else. When you turn the crown or tap a button on your wrist, the Watch doesn't change any state of its own. It sends a small WatchCommand back to the phone and waits for the phone to send a new snapshot.

The wire between them is WCSession. The phone pushes each snapshot over updateApplicationContext, which the system holds and delivers even if the Watch is asleep or out of range, always keeping only the latest value. The Watch sends its commands back over sendMessage. Two types, one wire, one owner of the truth. Almost every bug below is a version of the same mistake: one side trusting a copy of the truth that was already out of date.

How Musklr keeps the iPhone and Apple Watch in sync On the iPhone, WorkoutSessionManager builds a WorkoutSnapshot, the single source of truth for the workout. WCSession pushes it to the Watch over two transports at once: the guaranteed, coalesced updateApplicationContext, and a faster sendMessage path used only while the Watch is reachable. The Watch is a read-only display. It sends small WatchCommands back to the iPhone over sendMessage. IPHONE WorkoutSessionManager builds WorkoutSnapshot single source of truth WCSESSION updateApplicationContext guaranteed · OS-coalesced · latest value wins sendMessage fast path · Watch reachable only · near-immediate WatchCommand via sendMessage · queued if unreachable APPLE WATCH PhoneConnectivityManager receives WatchSessionViewModel read-only display
The iPhone owns all state. WorkoutSessionManager builds the WorkoutSnapshot and pushes it to the Watch over two transports at once: the guaranteed, coalesced updateApplicationContext, and a faster sendMessage path used only while the Watch is reachable. The Watch never writes its own copy; it only sends WatchCommands back.

That single source of truth is what makes the wrist feel instant. Musklr is free on the App Store, two taps to log a set.

Get Musklr free See everything Musklr does

Five sync bugs we found and killed

Every one of these shipped to nobody. We caught them building the wrist experience, and each turned out to be the same shape once we looked: the Watch and the phone briefly disagreeing about what was true. Here they are, symptom, cause, and fix.

Bug 1: The weight that snapped back to zero

Symptom. You scroll the crown to 100 kg on your wrist. It reads 100. A second or two later, with your hand nowhere near the watch, it jumps back to zero.

Cause. Every 15 seconds the phone re-pushes its last snapshot as a heartbeat, so a Watch that missed an update still converges. The trouble was that the cached snapshot still held the value from before your edit. When the heartbeat landed, the Watch treated it as fresh truth and cleared the optimistic value you had just dialed in.

Fix. Two halves. On the phone, when a crown edit arrives, we patch the cached lastPushedSnapshot in place so the next heartbeat carries your current value instead of the stale one. On the Watch, an optimistic value is cleared only when the phone confirms it (a snapshot for the same set carrying that value), or when the edit stops being relevant (the set changed, or you left the active-set screen). A heartbeat can no longer wipe an edit the phone hasn't confirmed.

Here is the Watch-side reconcile, alongside the phone-side patch that keeps the heartbeat honest:

// Source:
//   MusklrWatch Watch App/Services/WatchSessionViewModel.swift
//   Musklr/Services/Workout/WorkoutSnapshotPublisher.swift
//
// Beat: the Watch clears an optimistic crown-scroll value ONLY when the
// phone CONFIRMS it (a snapshot for the same set carrying that value) — or
// the edit becomes irrelevant (the set changed, or we left active-set) —
// never on a stale 15s heartbeat re-push. The phone-side companion keeps its
// own cached `lastPushedSnapshot` patched with in-flight edits so that same
// heartbeat re-pushes the CURRENT value instead of the pre-edit one.
// (commits 5fad1d8b, dd3c1b57)

import Foundation

// stub: trimmed WorkoutPhase — the real enum (MusklrShared/Models/WorkoutPhase.swift)
// has more cases (rest, betweenExercises, timedExercise, prCelebration, ...).
enum WorkoutPhase: String, Codable, Equatable {
    case activeSet, setComplete
}

// stub: trimmed WorkoutSnapshot — the real struct (MusklrShared/Models/WorkoutSnapshot.swift)
// carries dozens of fields; only the ones `reconcile` and `updateLastPushedValue` read are kept.
struct WorkoutSnapshot: Codable, Hashable {
    var phase: WorkoutPhase
    var value1: Double?
    var value2: Double?
    var currentSetId: String?
}

// MARK: - Watch side (WatchSessionViewModel.swift)

@MainActor
final class WatchSessionViewModel {

    private(set) var snapshot: WorkoutSnapshot

    init(snapshot: WorkoutSnapshot) {
        self.snapshot = snapshot
    }

    /// Local accumulator for the focused column while the user is scrolling.
    private var optimisticValue1: Double?
    private var optimisticValue2: Double?

    /// The value + set id last flushed to the phone, per column. An optimistic
    /// value is cleared only once the phone CONFIRMS it (a snapshot for the
    /// same set carrying that value) or the edit becomes irrelevant (the
    /// displayed set changed, or we left active-set). Replaces the old "clear
    /// whenever not mid-scroll" rule, which let a stale 15s heartbeat re-push
    /// wipe an unconfirmed edit.
    private var flushedValue1: Double?
    private var flushedValue2: Double?
    private var flushedSetId1: String?
    private var flushedSetId2: String?

    /// Debounce task — fires the actual send after the crown has been idle for 250ms.
    private var sendDebounceTask: Task<Void, Never>?

    /// Drop optimistic values only when the phone has confirmed them or the
    /// edit is no longer relevant — never on a stale heartbeat re-push.
    private func reconcileOptimisticValues(against snapshot: WorkoutSnapshot) {
        // Never disturb an edit the user is still actively scrolling.
        guard sendDebounceTask == nil else { return }
        reconcile(column1: true, against: snapshot)
        reconcile(column1: false, against: snapshot)
    }

    private func reconcile(column1: Bool, against snapshot: WorkoutSnapshot) {
        let optimistic = column1 ? optimisticValue1 : optimisticValue2
        guard optimistic != nil else { return }
        let flushedSetId = column1 ? flushedSetId1 : flushedSetId2
        let flushedValue = column1 ? flushedValue1 : flushedValue2
        let snapshotValue = column1 ? snapshot.value1 : snapshot.value2

        let leftActiveSet = snapshot.phase != .activeSet && snapshot.phase != .setComplete
        let setChanged = flushedSetId != nil && snapshot.currentSetId != flushedSetId
        let confirmed: Bool = {
            guard let flushedValue, let snapshotValue else { return false }
            return abs(snapshotValue - flushedValue) < 0.001
        }()

        guard leftActiveSet || setChanged || confirmed else { return }
        if column1 {
            optimisticValue1 = nil; flushedValue1 = nil; flushedSetId1 = nil
        } else {
            optimisticValue2 = nil; flushedValue2 = nil; flushedSetId2 = nil
        }
    }
}

// MARK: - Phone side (WorkoutSnapshotPublisher.swift)

@MainActor
final class WorkoutSnapshotPublisher {

    private(set) var lastPushedSnapshot: WorkoutSnapshot?

    /// Keep the cached `lastPushedSnapshot` consistent with a Watch value
    /// edit so the 15s heartbeat re-push (`rePushLast`) carries the CURRENT
    /// value instead of the stale one it was built with. Without this, the
    /// heartbeat re-pushes the pre-edit value; the Watch clears its optimistic
    /// value on arrival and reverts the user's edit ("weight snaps back to
    /// 0/55"). Emits NO push — it only mends the cached snapshot for the next
    /// heartbeat. Skips the patch when the cached snapshot has advanced past
    /// the edited set, so a value for a non-displayed set never smears on.
    func updateLastPushedValue(column1: Bool, value: Double, targetSetId: String?) {
        guard var snapshot = lastPushedSnapshot else { return }
        if let targetSetId, let current = snapshot.currentSetId, current != targetSetId {
            return
        }
        if column1 { snapshot.value1 = value } else { snapshot.value2 = value }
        lastPushedSnapshot = snapshot
    }
}
Musklr's active-set screen on Apple Watch during a leg press, showing 100 kg and 12 reps with a Set done button.
The active-set screen on the wrist: two columns, the crown to dial each one in, one tap to log. Bug 1 lived right here, in the left column snapping back to zero mid-edit.

Bug 2: The phantom "set 4 of 3"

Symptom. Finish the last set of a three-set exercise and the Watch flashes "Set 4 of 3", a set that does not exist.

Cause. One function, currentExerciseForActivity(), decides which exercise the Watch card describes. It would happily return an exercise whose sets were all complete, so the card counted one past the end.

Fix. An exercise now only counts as genuinely in progress when it has both a completed set and an incomplete set. A fully finished exercise falls through to the next candidate, so the card can never point one past the end.

The load-bearing rule is the middle scan, which requires both a completed and an incomplete set before an exercise can win:

// Source: Musklr/Services/Workout/WorkoutSessionManager.swift — currentExerciseForActivity()
//
// Beat: which exercise counts as "in progress" for the Watch / Live Activity
// card. A fully-completed exercise must NOT win the picker — it would render
// a phantom "set N+1 of N" and out-rank a freshly-added exercise that has no
// completed sets yet. The middle tier is the load-bearing rule: an exercise
// only qualifies as "genuinely in progress" when it has BOTH at least one
// completed set AND at least one incomplete set.
// (commit 381748e0)

import Foundation

// stub: trimmed Exercise — the real struct (Musklr/Models/Exercise/Exercise.swift)
// is a GRDB record with dozens of catalog fields; only `id` matters here.
struct Exercise: Identifiable, Equatable {
    let id: Int
}

// stub: trimmed WorkoutSet — the real struct (Musklr/Models/Workout/WorkoutSet.swift)
// carries weight/reps/duration/target fields; only `isCompleted` matters here.
struct WorkoutSet: Identifiable, Equatable {
    let id: String
    var isCompleted: Bool
}

final class WorkoutSessionManager {

    /// User-pinned "current" exercise (e.g. tapped from the workout list).
    var anchorExerciseId: Int?

    /// Session exercises in display order, each with its resolved sets.
    /// Real property is `[(exerciseId: Int, exercise: Exercise, sets: [WorkoutSet])]`.
    var sessionExercises: [(exerciseId: Int, exercise: Exercise, sets: [WorkoutSet])] = []

    /// Resolve which exercise the Watch / Live Activity card should describe
    /// right now.
    func currentExerciseForActivity() -> Exercise? {
        // Anchor wins when set, resolvable, AND still has incomplete sets.
        // If the anchored exercise is fully complete we fall through to scan
        // order so a different incomplete exercise can become "current".
        if let anchor = anchorExerciseId,
           let group = sessionExercises.first(where: { $0.exerciseId == anchor }),
           group.sets.contains(where: { !$0.isCompleted }) {
            return group.exercise
        }
        // Find the last exercise that is genuinely IN PROGRESS — has at least
        // one completed set AND at least one incomplete set. A fully-completed
        // exercise must not win here: it would render a phantom "set N+1 of N"
        // on the Watch and out-rank a freshly-added incomplete exercise that has
        // no completed sets yet. Fully-complete exercises fall through to the
        // first-incomplete scan below.
        for group in sessionExercises.reversed() {
            if group.sets.contains(where: { $0.isCompleted }),
               group.sets.contains(where: { !$0.isCompleted }) {
                return group.exercise
            }
        }
        // Fallback: first exercise with incomplete sets
        for group in sessionExercises {
            if group.sets.contains(where: { !$0.isCompleted }) {
                return group.exercise
            }
        }
        return sessionExercises.first?.exercise
    }
}

Bug 3: "Workout done" when you weren't, and the black box

Symptom. Finish the last set you had queued and the Watch flips to "workout done", while the Lock Screen Live Activity goes black. You weren't done. You were about to add another exercise.

Cause. When the rest timer expired and nothing was left incomplete, advanceToNextPhase() pushed .workoutComplete straight away. But the session is still open at that point. On the Watch that read as finished, and the Live Activity, handed a completed workout with no content to draw, rendered an empty view: a black box on your Lock Screen.

Fix. When every current set is done but the session is still open, we route to a between-exercises waiting state ("All sets done. Add an exercise on your phone, or finish.") instead of completing. Only the explicit Finish button reaches .workoutComplete.

When every current set is done but the session is still open, we push the waiting state rather than completing the workout:

// Source: Musklr/Services/Workout/WorkoutSnapshotPublisher.swift — advanceToNextPhase()
//
// Beat: when the rest timer expires (or a no-rest set completes) and every
// current set is done, the session is still OPEN — the user may add another
// exercise. Auto-advancing straight to `.workoutComplete` here made the Watch
// show "workout done" and the Live Activity render an EmptyView (a black box)
// while the session was still live. Route to the between-exercises WAITING
// state instead; only the explicit Finish button reaches `.workoutComplete`.
// (commits 98eba51c, 2afd8387)

import Foundation

// stub: trimmed WorkoutPhase — the real enum has more cases.
enum WorkoutPhase: String, Equatable {
    case activeSet, betweenExercises, workoutComplete
}

// stub: trimmed WorkoutSnapshot — only what this beat needs to build/carry.
struct WorkoutSnapshot {
    var phase: WorkoutPhase
}

// stub: trimmed WorkoutSet — only `exerciseId`/`isCompleted` matter here.
struct WorkoutSet {
    let exerciseId: Int
    var isCompleted: Bool
}

// stub: trimmed WorkoutSessionManager surface this beat reads.
final class WorkoutSessionManager {
    var sessionSets: [WorkoutSet] = []

    /// Most recently completed set, or nil if nothing has been completed yet.
    func lastCompletedSet() -> WorkoutSet? {
        sessionSets.last(where: \.isCompleted)
    }
}

final class WorkoutSnapshotPublisher {
    private unowned let manager: WorkoutSessionManager

    init(manager: WorkoutSessionManager) { self.manager = manager }

    // stub: real implementations build + push a full WorkoutSnapshot to
    // Watch + Live Activity; only the phase-selection logic below is the point.
    private func pushToAllSurfaces(_ snapshot: WorkoutSnapshot) {}
    private func pushPhase(_ phase: WorkoutPhase) {}
    private func buildBetweenExercisesSnapshot() -> WorkoutSnapshot {
        WorkoutSnapshot(phase: .betweenExercises)
    }

    /// Called when the rest timer expires. Chooses the appropriate next phase
    /// and pushes a fresh snapshot. Replaces the previous "do nothing" behavior
    /// that left the Watch stuck on "0:00 · Skip rest".
    ///
    /// Phase selection:
    /// - `.activeSet` — the exercise of the most-recently-completed set still
    ///   has incomplete sets
    /// - `.betweenExercises` — the current exercise is finished, whether or
    ///   not other exercises still have incomplete sets. `.workoutComplete`
    ///   is never pushed from here — the session is still open and the user
    ///   may add another exercise; only the explicit Finish button
    ///   (`completeSession`) reaches `.workoutComplete`.
    func advanceToNextPhase() {
        let remainingIncomplete = manager.sessionSets.filter { !$0.isCompleted }
        if remainingIncomplete.isEmpty {
            // Every current set is done, but the SESSION is still open — the
            // user may add another exercise. Do NOT auto-complete: that made
            // the Watch show "workout done" and the Live Activity render
            // EmptyView (a black box) while the session was still live. Show
            // the between-exercises waiting state instead; the explicit Finish
            // button (`completeSession`) is the only path to `.workoutComplete`.
            pushToAllSurfaces(buildBetweenExercisesSnapshot())
            return
        }

        let lastCompletedExerciseId = manager.lastCompletedSet()?.exerciseId
        let hasMoreInSameExercise: Bool = {
            guard let id = lastCompletedExerciseId else { return false }
            return manager.sessionSets.contains { $0.exerciseId == id && !$0.isCompleted }
        }()

        if hasMoreInSameExercise {
            pushPhase(.activeSet)
        } else {
            pushToAllSurfaces(buildBetweenExercisesSnapshot())
        }
    }
}

Every one of these fixes ships in Musklr today. Log your next set from your wrist, free.

Get Musklr free See everything Musklr does

Bug 4: Add an exercise, and the Watch ignores it

Symptom. You finish everything, then add a fresh exercise on the phone. The Watch keeps showing the old, finished one and never mentions the new set.

Cause. The same currentExerciseForActivity() from bug 2. The just-finished exercise, with all sets complete, still won the picker and out-ranked the new exercise, which had no completed sets yet.

Fix. The same in-progress rule solves it for free. A fully complete exercise can no longer win, so the new incomplete exercise is the one the Watch picks up. Two bugs, one predicate.

This is the same predicate from bug 2, doing double duty.

Bug 5: The wait after every wrist action

Symptom. Skip a rest or stop a timer on the wrist and the screen just sits there, for up to several seconds, before it catches up.

Cause. Snapshots went out only over updateApplicationContext. That transport is the right one for guaranteed background delivery, but the system coalesces it and can hold it for several seconds. That is invisible for a background refresh and far too slow for a transition you triggered a moment ago.

Fix. Two parts. The Watch flips its own phase immediately on an unambiguous action, so the UI never waits on the wire (optimistic UI). And when the Watch is reachable, the phone now sends the identical snapshot over sendMessage too, a near-immediate fast path, while updateApplicationContext stays the guaranteed backstop. Both carry the same payload, so the Watch simply keeps whichever arrives first and ignores the duplicate.

The phone sends over both transports at once, the guaranteed one and the fast one:

// Source: Musklr/Services/Watch/WatchConnectivityManager.swift — push(_:)
//
// Beat: every snapshot goes out over `updateApplicationContext` — OS-coalesced,
// but GUARANTEED delivery even if the Watch is unreachable right now. When the
// Watch IS currently reachable, the same payload is ALSO sent via `sendMessage`
// as a near-immediate fast-path accelerator, because `updateApplicationContext`
// can lag several seconds. The Watch dedups the later application-context copy
// against the same payload, so the dual send is safe, not a double-apply.
// (commits 2044b158, 518507be)

import Foundation
import WatchConnectivity

// stub: trimmed WorkoutSnapshot — only the wire encoding matters here.
struct WorkoutSnapshot {
    var phase: String

    func toDictionary() -> [String: Any] {
        ["phase": phase]
    }
}

// stub: real gate (Musklr/Services/Watch/WatchPushGate.swift) also checks
// activation state and the user's sync preference; collapsed to a bool here.
enum WatchPushGate {
    static func shouldPush(
        isSupported: Bool,
        activationState: WCSessionActivationState,
        isWatchAppInstalled: Bool,
        syncEnabled: Bool
    ) -> Bool {
        isSupported && isWatchAppInstalled && syncEnabled && activationState == .activated
    }
}

// stub: real preference (Musklr/Services/Watch/WatchSyncPreference.swift) is
// backed by UserDefaults; collapsed to a constant here.
enum WatchSyncPreference {
    static let isEnabled = true
}

// stub: minimal logging shim standing in for the app's Sentry-bound Logger.
enum Logger {
    static func log(_ message: String, category: String, type: String = "default") {}
    static func debug(_ message: String, category: String) {}
    static func error(_ message: String, category: String) {}
}

final class WatchConnectivityManager: NSObject {

    /// Push a WorkoutSnapshot to the Watch via application context.
    /// Uses updateApplicationContext so the Watch always has the latest state,
    /// even if it's not currently reachable.
    func push(_ snapshot: WorkoutSnapshot) {
        let supported = WCSession.isSupported()
        guard WatchPushGate.shouldPush(
            isSupported: supported,
            activationState: WCSession.default.activationState,
            isWatchAppInstalled: supported && WCSession.default.isWatchAppInstalled,
            syncEnabled: WatchSyncPreference.isEnabled
        ) else {
            // Either sync disabled by the user, not activated, or no
            // counterpart Watch app installed. Skip silently (debug) instead
            // of letting updateApplicationContext throw
            // WCErrorCodeWatchAppNotInstalled (7006) on every push + the 15s
            // heartbeat, which floods the log and Sentry.
            Logger.debug(
                "Skipping snapshot push — gate closed (syncEnabled=\(WatchSyncPreference.isEnabled), activated=\(WCSession.default.activationState == .activated), watchInstalled=\(WCSession.default.isWatchAppInstalled))",
                category: "watchConnectivity"
            )
            return
        }
        let context = snapshot.toDictionary()
        guard !context.isEmpty else { return }
        do {
            try WCSession.default.updateApplicationContext(context)
            // Fast-path: updateApplicationContext is OS-coalesced and can lag
            // several seconds. When the Watch is reachable, ALSO deliver via
            // sendMessage (near-immediate) so command-triggered transitions
            // (stop/start timer, skip/adjust rest) reflect without the delay.
            // Same payload → the Watch dedups the later application-context copy.
            if WCSession.default.isReachable {
                WCSession.default.sendMessage(context, replyHandler: nil) { error in
                    // Transient + self-healing: the Watch can go unreachable in
                    // the window between the isReachable check and delivery
                    // (documented WCSession race — e.g. the Watch app
                    // backgrounds mid-workout), so sendMessage fails with a
                    // WCError. The updateApplicationContext copy above already
                    // succeeded and guarantees delivery, so this accelerator
                    // failing is benign — log at .info, NOT Logger.error (which
                    // is Sentry-bound and would flood noise on every flap).
                    Logger.log(
                        "Fast-path snapshot sendMessage failed (benign — application context still delivers): \(error)",
                        category: "watchConnectivity",
                        type: "info"
                    )
                }
            }
            Logger.log(
                "Pushed snapshot to Watch — phase=\(snapshot.phase) reachable=\(WCSession.default.isReachable)",
                category: "watchConnectivity"
            )
        } catch {
            Logger.error("Failed to push snapshot: \(error)", category: "watchConnectivity")
        }
    }
}

And the Watch doesn't wait for either to land when the next phase is unambiguous:

// Source: MusklrWatch Watch App/Services/WatchSessionViewModel.swift
//   — skipRest(), stopTimedExercise(), applyOptimisticPhase(_:)
//
// Beat: the Watch flips its LOCAL phase immediately on a user action, before
// the phone round-trip completes, so the UI never waits on WatchConnectivity
// latency for an unambiguous transition. The next phone snapshot (whichever
// arrives first — the `sendMessage` fast-path or the held
// `updateApplicationContext`) overwrites it wholesale via latest-wins.
// `skipRest` only takes this shortcut when the target phase is UNAMBIGUOUS:
// a rest WITHIN an exercise always returns to `.activeSet`, but a rest that
// was the exercise's LAST set advances to `.betweenExercises` — a phase whose
// content (the picker list) this rest snapshot doesn't carry, so guessing it
// locally would risk a rest→activeSet→between double transition or an empty
// picker. That case is left to the phone's fast-path reply instead.
// (commits 2044b158, 518507be)

import Foundation

// stub: trimmed WorkoutPhase — the real enum has more cases.
enum WorkoutPhase: Equatable {
    case activeSet, rest, betweenExercises
}

// stub: trimmed WorkoutSnapshot — only the fields this beat reads/writes.
struct WorkoutSnapshot {
    var phase: WorkoutPhase
    /// Rest phase only: true when SKIPPING this rest advances to
    /// `.betweenExercises` (the just-completed set was its exercise's last).
    var restLeadsToBetweenExercises: Bool?
}

// stub: trimmed WatchCommand — the real enum carries payloads for every
// Watch → phone action (updateValue1/2, completeSet, adjustRest, ...).
enum WatchCommand {
    case skipRest
    case stopTimedExercise
}

// stub: trimmed transport protocol — the real `PhoneConnectivityManaging`
// also exposes `snapshot`/`snapshotPublisher`/`isReachablePublisher`.
protocol PhoneConnectivityManaging {
    func send(_ command: WatchCommand)
}

@MainActor
final class WatchSessionViewModel: ObservableObject {

    @Published var snapshot: WorkoutSnapshot
    let connectivity: any PhoneConnectivityManaging

    init(snapshot: WorkoutSnapshot, connectivity: any PhoneConnectivityManaging) {
        self.snapshot = snapshot
        self.connectivity = connectivity
    }

    func skipRest() {
        connectivity.send(.skipRest)
        // Only optimistically flip when the next phase is unambiguous — a rest
        // WITHIN an exercise returns to .activeSet. When the just-completed set
        // was the exercise's last, the phone advances to .betweenExercises, whose
        // content (allIncompleteExercises) this rest snapshot doesn't carry, so a
        // local flip would cause a rest→activeSet→between double transition (or an
        // empty picker). Leave the rest view up; the phone's fast-path reply
        // delivers the correct next phase with its content in ~sub-second.
        if snapshot.restLeadsToBetweenExercises != true {
            applyOptimisticPhase(.activeSet)
        }
    }

    func stopTimedExercise() {
        connectivity.send(.stopTimedExercise)
        applyOptimisticPhase(.activeSet)
    }

    /// Flip the local phase now; the next phone snapshot (fast-path or
    /// application context) overwrites it wholesale via the sink's latest-wins.
    private func applyOptimisticPhase(_ phase: WorkoutPhase) {
        var optimistic = snapshot
        optimistic.phase = phase
        snapshot = optimistic
    }
}

Reading about sync is one thing. Feel it. Musklr logs a set in two taps and works offline.

Get Musklr free See everything Musklr does

The fix that made a new bug

The optimistic flip from bug 5 was a little too eager. Skipping a rest normally returns you to the active set, so the Watch flipped straight to .activeSet. But when the rest you skipped came right after the last set of an exercise, the phone advances to .betweenExercises instead. So the Watch would flip to .activeSet, then the phone's real snapshot would arrive and flip it again to .betweenExercises: a rest, active, between double cross-fade, or a flash of an empty picker, since the rest snapshot doesn't carry the next-exercise list.

The phone already knows the answer, though. At the moment it builds the rest snapshot, it knows whether skipping will land on between-exercises, so it stamps that onto restLeadsToBetweenExercises. The Watch reads the hint and only takes the optimistic shortcut when the target is unambiguous. When it isn't, the Watch leaves the rest screen up and waits for the phone's fast-path reply, which now arrives in well under a second. Predict only what you can predict correctly.

The phone stamps the prediction onto the rest snapshot, and the Watch reads it before it decides whether to flip:

// Source: Musklr/Services/Workout/WorkoutSnapshotPublisher.swift — buildRestSnapshot(...)
//
// Beat (bonus): the phone computes, at the moment it builds the rest
// snapshot, whether SKIPPING this rest will land on `.betweenExercises`
// (the just-completed set was its exercise's last) or `.activeSet` (the
// exercise still has incomplete sets) — and stamps that prediction onto
// `restLeadsToBetweenExercises`. The Watch reads this hint in `skipRest()`
// (see optimistic-phase.swift) to decide whether an optimistic local phase
// flip is safe, avoiding a rest→activeSet→between double transition.
// (commit c531cd56)

import Foundation

// stub: trimmed WorkoutPhase — the real enum has more cases.
enum WorkoutPhase: Equatable {
    case rest
}

// stub: trimmed Exercise — only the display name matters here.
struct Exercise: Equatable {
    let id: Int
    let localizedName: String
}

// stub: trimmed WorkoutSet — only what next-exercise resolution needs.
struct WorkoutSet {
    let exerciseId: Int
}

// stub: trimmed WorkoutSnapshot — only the rest-phase fields this beat sets.
struct WorkoutSnapshot {
    var phase: WorkoutPhase
    var restEndDate: Date?
    var restTotalSeconds: Int?
    var nextSetDetail: String?
    /// Rest phase only: true when SKIPPING this rest advances to
    /// `.betweenExercises` (the just-completed set was its exercise's last),
    /// false/nil when it returns to `.activeSet`.
    var restLeadsToBetweenExercises: Bool?
}

@MainActor
final class WorkoutSessionManager {
    var sessionExercises: [(exerciseId: Int, exercise: Exercise)] = []

    func lastCompletedSet() -> WorkoutSet? { nil }
    func isExerciseFullyCompleted(_ exerciseId: Int) -> Bool { false }
    func firstIncompleteSetOfNextExercise(after exerciseId: Int) -> WorkoutSet? { nil }
}

@MainActor
final class WorkoutSnapshotPublisher {
    private unowned let manager: WorkoutSessionManager

    init(manager: WorkoutSessionManager) { self.manager = manager }

    // stub: the real builder assembles the full snapshot (exercise name, set
    // progress, previous performance, ...); collapsed here to just the phase.
    private func buildSnapshot(phase: WorkoutPhase, overrideExercise: Exercise?) -> WorkoutSnapshot {
        WorkoutSnapshot(phase: phase)
    }

    /// Build a rest-phase snapshot with rest-specific fields.
    ///
    /// When the just-completed set was the last of its exercise, the rest
    /// screen's "up next" card must reference the FIRST incomplete set of
    /// the NEXT exercise — not an imaginary extra set of the one just
    /// finished.
    func buildRestSnapshot(restEndDate: Date, restTotalSeconds: Int) -> WorkoutSnapshot {
        let lastCompleted = manager.lastCompletedSet()
        let lastExerciseFullyCompleted = lastCompleted.map {
            manager.isExerciseFullyCompleted($0.exerciseId)
        } ?? false
        let overrideExercise: Exercise? = {
            guard let lastCompleted, lastExerciseFullyCompleted,
                  let nextSet = manager.firstIncompleteSetOfNextExercise(after: lastCompleted.exerciseId) else {
                return nil
            }
            return manager.sessionExercises.first(where: { $0.exerciseId == nextSet.exerciseId })?.exercise
        }()

        var snapshot = buildSnapshot(phase: .rest, overrideExercise: overrideExercise)
        snapshot.restEndDate = restEndDate
        snapshot.restTotalSeconds = restTotalSeconds
        // Same signal the override uses: when the just-completed set was its
        // exercise's last, skipping this rest advances to `.betweenExercises`.
        // The Watch reads this hint to pick the correct optimistic target and
        // avoid a rest→activeSet→between double transition.
        snapshot.restLeadsToBetweenExercises = lastExerciseFullyCompleted

        // When the rest is between exercises (override applied), the user
        // wants to know which exercise is up next — not the weight×reps of
        // its first set. They need to walk to a new station / mentally
        // prepare. Replace the detail with just the exercise name in that
        // case; the within-exercise rest path keeps its weight×reps detail.
        if let override = overrideExercise {
            snapshot.nextSetDetail = override.localizedName
        }

        return snapshot
    }
}

Two ideas that fixed all of it

Optimistic UI: predict, then reconcile

Show the result the instant someone acts, then let the source of truth confirm or correct it. The crown value and the phase flip both work this way. The discipline is entirely in the reconcile step: predict only when the outcome is unambiguous, and clear a prediction only on a real confirmation, never on a stale echo. Get that wrong and you get bug 1, where a heartbeat cleared a value it had no business clearing, or the double cross-fade, where the Watch guessed a phase it couldn't see. Get it right and the wrist just feels instant.

One source of truth

The iPhone owns every piece of workout state. The Watch is a read-only display that renders the latest WorkoutSnapshot and sends back small WatchCommands. No shared database, no second copy to drift out of date. Every bug above was some version of the Watch trusting the wrong copy, or the phone shipping a stale one. When exactly one side owns the truth, "which value is right" always has an answer, and that is what makes the rest of the system simple enough to reason about.

What it feels like now

Now the wrist just keeps up. Scroll to your weight and it stays put. Finish a set and the next one is already there. Skip a rest and the screen moves the moment your finger leaves the crown. Add an exercise on the phone and the watch is showing it before you've set the phone down. None of this is visible when it works, and that is exactly the point. Good sync is something you only ever notice when it's missing.

Musklr's new-record celebration on Apple Watch after a 140 kg leg press, with confetti.
A new record, detected on the phone and celebrated on the wrist. When the sync is right, this just shows up.

Try logging from your wrist

Musklr logs a set in two taps, on iPhone and Apple Watch, and it works offline. It's free to use for as long as you like, with no account needed. Pro is optional, about $2 a month, for extras like unlimited routines and cloud sync. It's free on the App Store.

Download Musklr on the App Store

See everything Musklr does on the home page →