How do you build a sync protocol between an Apple Watch and an iPhone?
You borrow one. Musklr takes client-side prediction from multiplayer game networking, sequence numbers with an exclusive cumulative acknowledgement from TCP, and a per-launch incarnation token from Kafka's idempotent producer. Watch commands travel as events and get exactly-once delivery in order. Phone snapshots travel as state and get a revision number, where newer simply wins.
Why do an Apple Watch and iPhone disagree about a workout in progress?
A lifter is mid-set. The phone is in a locker, on a bench, or face down on the floor, and the Watch is the surface they actually read. They finish the set and tap.
Three things can be true at that moment:
- the phone is reachable and answers in milliseconds
- the phone is reachable but the reply takes seconds
- the phone is not reachable at all, and will not be for the rest of the workout
The wrist has to behave identically in all three. That is the whole design brief, and it is the brief a multiplayer game has when a player presses fire.
Eight inches of air is not what makes two Apple devices disagree. Both of them hold a copy of the same workout, and the messages between them can arrive late, out of order, twice, or never.
Part I, on keeping an Apple Watch and an iPhone in sync during a workout, listed five of those disagreements and how each was fixed. Every fix was correct on its own, and none of them stopped the sixth from arriving, because five patches are not a protocol. A protocol is a small set of rules both devices obey, so the next disagreement is prevented rather than discovered.
We did not invent ours. It came from three places that solved the same problem decades earlier, and the interesting part is the mapping, plus the places where the mapping stops holding. All of it lives on one unmerged branch, pinned by unit tests rather than by two devices in a gym, and the last section is specific about what that leaves open.
What is client-side prediction, and how does an Apple Watch app use it?
The canonical treatment is Gabriel Gambetta's Fast-Paced Multiplayer, and its second part, Client-Side Prediction and Server Reconciliation, is the one this post borrows from. The client does not wait for the server to agree before it shows the result of an input. It predicts the outcome, draws it straight away, and reconciles when the authoritative answer catches up.
The mapping onto a workout app is almost one to one:
| Gambetta | Musklr |
|---|---|
| player input | a tap on the Watch |
| client prediction | the wrist confirms the set immediately |
| server | the phone, which owns the database |
| server reconciliation | the phone's acknowledgement, carried on the next snapshot |
| authoritative state | WorkoutSnapshot |
The phone stays authoritative. It owns the database and it stores every set, and the Watch never writes to the workout database. What changed is that the Watch stopped waiting to be told its tap counted.
That is Musklr on a real wrist, not a mockup. It is free to use for as long as you like, needs no account, and logs a set in two taps.
Get Musklr free See everything Musklr doesWhere the analogy stops. A game discards a mispredicted frame, re-simulates from the last authoritative state, and the player sees a small correction. A logged set cannot be un-lifted for someone standing at a barbell. There is no rollback path on the wrist and there is deliberately not going to be one.
So the prediction is bounded by what the reader can already see: the tap, and the value currently on the crown, which is on the screen in front of them whether the phone has agreed to it yet or not. Where a truthful number cannot be stated, the confirmation drops the number and keeps the confirmation. The set count is omitted once the offset runs past the exercise's set count, and a set the wrist cannot name is not confirmed at all. Showing a lift the reader did not make is worse than showing less.
How does TCP guarantee that every message arrives exactly once, in order?
Watch commands are an event stream. Every one has to arrive, exactly once, in the order it was sent. That is precisely TCP's problem, so we took TCP's answer and kept the RFC's own names for the parts. The current specification is RFC 9293, published in 2022, which obsoletes RFC 793.
Four mechanisms do the work.
Every command the Watch sends carries a sequence number that only
ever increases. The phone echoes back how far it has got, a
cumulative acknowledgement that TCP calls RCV.NXT and
that the next section is entirely about.
A command numbered below what the phone is waiting for is dropped rather than applied a second time. That duplicate rejection is what makes retransmission safe to attempt at all: without it, one retry logs a second set that nobody lifted.
A command that arrives early is buffered rather than applied.
Order matters because a command's effect depends on the state the
one before it left behind: a .completeSet that has
run out of set ids to name falls back to resolving positionally,
and a rest adjustment sums its delta onto whatever the previous
one produced. WCSession.sendMessage offers no
ordering guarantee of its own, so an early command is held until
the gap in front of it closes, then applied with the rest of the
queue in one contiguous run.
None of this is clever. It is a forty-year-old design being read carefully and copied on purpose.
Why does TCP acknowledge the next expected sequence number instead of the last one received?
This is the most important choice on the branch, and the first implementation got it wrong in the obvious way: an inclusive watermark, where the phone echoes the last sequence number it applied and the Watch discards everything at or below it. It works, it is easy to reason about, and it answers exactly one question: it proves the phone applied at least command N.
That is not the question a sender needs answered. It needs to know which command went missing, and an inclusive watermark can never say that command K specifically was lost. It reports progress, and progress is silent about gaps.
TCP acknowledges exclusively. RCV.NXT is, in the
RFC's own words, "the next sequence number expected on an incoming
segment, and is the left or lower edge of the receive window" (RFC 9293, section 3.4). The receiver is not reporting what it has done. It is naming
the one thing it is waiting for.
So a gap stops the acknowledgement dead. Command 41 arrives, the phone answers 42, and every later command piles up in the buffer while the answer stays at 42. It does not creep forward. It sits there naming the missing command until that command arrives, and then the buffered run drains in order and the acknowledgement jumps past all of it in one step.
The flip from inclusive to exclusive is not a cosmetic rename. It is the difference between a channel that knows it is stuck and one that only knows it is behind. In Musklr the phone sets the value to one past whatever it just applied, at all three places where the value can move, and the Watch drops strictly below it: a command equal to the acknowledgement is still expected, one below it is a duplicate. Both boundaries have a test whose whole job is to sit on them.
Why must a retransmitted message keep its original sequence number?
Because the number identifies a position in the stream rather than an attempt at sending, and the receiver is holding that position open.
Our first implementation re-stamped. transmit()
assigned a sequence number K, and when the send failed, the error
handler requeued the command with no number at all, so the replay
minted a fresh one. The commit that fixed it says what that costs:
"K was burned and never arrived", and once strict ordering was in
place, "one failed send froze the whole control path". The
receiver waits forever for a number the sender no longer has,
every later command queues behind a gap that can never close, and
the Watch stops working as an input device.
So a resend goes out under the number it first went out under, and a command requeued after a failure is put back under the number already burned for it. The sender's high-water mark is guarded too, so re-recording an older number cannot drag it backwards.
The recovery is deliberately small. When the acknowledgement has not moved for five seconds, the sender offers exactly one command for retransmission: the one the acknowledgement is naming. Everything else is already applied or already buffered on the far side, and resending it is a lot of radio for no new information.
Why does exactly-once delivery break when the sender restarts?
Sequence numbers are only unique within one run of the sender. Restart it and the counter has to come back from somewhere, and wherever it comes from is a chance to collide with numbers the receiver has already seen.
TCP answers this in RFC 9293, section 3.4.1, seeding the initial sequence number from a clock so a restarted connection cannot reuse numbers an earlier one already sent. Musklr does the same: the Watch's counter is seeded from a millisecond epoch and floored by the last value that device ever sent, so a backwards clock cannot walk it back either.
That fixes reuse and creates a second problem. A clock-seeded counter does not resume, it jumps, by however much wall-clock time passed across the relaunch, and a receiver gating strictly on order reads that jump as an enormous gap. One of our tests is called
testNewIncarnationResynchronisesInsteadOfReportingATrillionSeqGap
which is the failure it was written against.
TCP settles identity during connection establishment. We have no handshake, so identity travels on the payload. Kafka's idempotent producer solves it the same way: KIP-98 de-duplicates on a producer id, an epoch and a sequence, and the broker rejects a stale epoch. Kafka's own documentation states the caveat that matters here: without a transactional id, "the producer is limited to idempotent delivery", scoped to a single producer session.
Musklr carries the same job in a smaller shape, a pair rather than a triple: one random token generated per Watch app launch and never persisted, collapsing Kafka's producer id and epoch into a single field stamped on every command. When the phone sees a token it has not seen before, it does not buffer and it does not wait for a gap belonging to a previous launch. It resynchronises, accepts the arriving command as the new starting point, and discards anything buffered from the old incarnation. That costs a field on every message, and it is worth it.
When does a sync protocol need guaranteed delivery, and when is newer-wins enough?
The most useful idea in the exercise is knowing where to stop applying the rest of it.
Two things travel between these devices, and they are not the same kind of thing:
| Property | Watch to phone | phone to Watch |
|---|---|---|
| carries | a command, an event | a snapshot, full state |
| example | "I finished a set" | "here is the whole workout right now" |
| losing one costs | a set the lifter actually did | nothing, the next push carries everything |
| needs | exactly-once, in order, retransmission | ordering only, newer wins |
Commands get the entire TCP apparatus above. Snapshots get one revision number and a rule that refuses anything strictly older, and that is all: no acknowledgement, no retransmission, no gap detection, because the phone pushes a full snapshot on a heartbeat roughly every fifteen seconds and on every phase change. A lost snapshot is repaired by the next one without anybody asking.
Retransmitting a stale snapshot would be actively wrong. It describes a workout that has already moved on, so delivering it late can only make the wrist older than it was.
The reverse mistakes cost differently. Apply delivery guarantees to state and you spend radio and battery resending data that was already obsolete when it failed. Apply newer-wins to events and you silently drop a set, which is the one failure a training log cannot have, because the lifter did the work and the app disagrees.
So there are two counters, one per direction, and one detail falls out of the split: an equal revision is still adopted rather than dropped as not-newer, because only full deliveries bump the number. Newer-wins means refusing older. It does not mean refusing again.
Which half of the traffic can be dropped, and which cannot, is most of the work in a training log. That log is Musklr: free on iPhone and Apple Watch, and it logs a set in two taps.
Get Musklr free See everything Musklr doesWhy does a per-process counter break when you put it on the wire?
Because a counter that starts at zero on every launch is making a promise only its own process can keep.
We gave snapshots a revision number so a reader could tell newer from older, and it was a plain per-process counter starting at zero. That is correct for its original job, cancelling a push that a newer one overtook inside a single run of the app. Nothing about that job cares what the number was yesterday.
Then it went on the wire and picked up a second job, carrying a requirement the first never had. A phone that restarts mid-workout comes back publishing revision 1 while the Watch is holding revision 40. The Watch applies the rule it was given, correctly, decides the incoming state is older than what it has, and discards it. Then it keeps discarding for the rest of the session, because the phone has 39 snapshots of climbing to do first.
Mid-workout terminations are not hypothetical. Musklr reports them to Sentry, as "Workout session resumed after unexpected termination", precisely so they can be counted.
The fix is the seeding from the previous section: the same clock-and-floor seed as the command counter, under its own key, with a test whose only job is to prove the two never share a value.
What made this hard to see is not the counter. One variable had two jobs, only one of them tolerated a restart, and the job with the harder requirement was added later by somebody who did not have the first job in mind.
Can one rejected message deadlock an entire command channel?
Yes, and ours did.
Every command carries the workout session it was created for, and the phone refuses a command naming a session that has ended. That guard earns its place: without it, a command queued at the end of one workout drains into the next one and writes a stale value onto its first set.
It originally ran before the sequence receiver, deliberately, on the reasoning that a command the phone is about to throw away should not advance the acknowledgement. In isolation that is sound, and the isolation is the problem.
Then strict ordering arrived. The refusal was now permanent, so the acknowledgement parked on the refused command's number and stayed there. The Watch did what a retransmitting sender should and resent the command the acknowledgement named, and the phone refused it again, every time. Every later command, including every command for the workout that actually was active, sat buffered behind a gap that could never close. The commit that fixed it puts it plainly: "The Watch stopped working as an input device for the rest of its launch."
The fix is an ordering change. The session guard now runs inside the apply step, after the sequence receiver has already moved on, so a refused command consumes its sequence number and the stream carries on without it. Two tests hold that in place: one sends sequence 42 naming a session that has ended and asserts the acknowledgement comes back as 43 with nothing applied, the other that the next command for the live session applies normally, and a command drained out of the buffer now goes through the guard too.
Two mechanisms, each correct alone, fatal together.
Why should an optimistic UI never read its confirmation from server state?
Because server state is, by definition, the thing an optimistic UI is not waiting for.
The wrist shows the value you are dialling before the phone has echoed it back. The confirmation overlay, though, read its numbers from the phone's last snapshot.
So: dial 50 kg up to 55, tap to finish the set before the echo arrives, and the checkmark and the success haptic confirm 50 kg. The app told a lifter, with a physical tap on their wrist, that something had happened which had not.
Every part of that is correct on its own. The crown edit lives locally until the phone agrees, which is the point of prediction. The snapshot is the authoritative state, which is why the overlay read it. Line them up and the app tells an untruth to somebody standing at a barbell.
The fix moves the decision to the caller. The formatter no longer reaches for the snapshot, it is handed the numbers, and the call site hands it the values the wrist is showing.
This is the sharp edge of client-side prediction and the reason ours is bounded to what the reader can already see. A mispredicted frame in a game costs a frame, and the frame gets redrawn. A false confirmation on a wrist costs a set in somebody's training log, and then it costs their belief in the rest of the log.
What is the size limit for an ActivityKit Live Activity content state?
4 KB, and there is less of it than that sounds, because Apple caps the static attributes and the dynamic content state combined rather than the content state on its own.
ActivityKit caps a Live Activity's data at 4 KB, and
Activity.update(_:) is not a throwing call, so there
is no error to catch. Apple does not document what happens when
you go over. What we watched happen is that the Lock Screen card
simply stopped updating.
Now the shared type. WorkoutSnapshot is the wire type
for the Watch and for the Live Activity both, so every
command-channel field added for the Watch, the sequence numbers,
the acknowledgement, the session id, rode straight into
ActivityKit's budget without anybody deciding that it should. On a
20-exercise workout the between-exercises content state measures
4211 bytes with nothing stripped out. Stripping the Watch-only
fields at the boundary takes it to 1435, and the attributes still
have to fit alongside that.
There are two tests now. One asserts the stripped snapshot fits inside the cap, which is the reassuring one. The other asserts the unstripped snapshot exceeds it, which is the useful one, because it fails the moment the stripping stops working rather than the moment a card goes quiet on somebody's Lock Screen. Before this branch there was no size test at all, so nobody knew.
A shared type is a shared budget.
Is Swift's Int 32-bit on Apple Watch?
Yes, on the arm64_32 slice watchOS ships to older Apple Watch models. Worth checking rather than taking anyone's word for, and the check is one line:
xcrun --sdk watchos clang -arch arm64_32 -dM -E -x c /dev/null
It reports __ILP32__ 1 and a four-byte pointer.
Now put that beside a counter seeded from a millisecond epoch, which is a number around 1.7 trillion. It does not fit in 32 bits. The types are explicitly 64-bit everywhere it matters, on the wire and in storage, and that was deliberate from the start.
The trap was the one place the value passed back through a native
Int. Reading the stored floor with
UserDefaults.integer(forKey:) gives you an
Int, and on the Watch that traps, at launch, on every
launch, on every device with that slice. The seed is now written
and read without ever touching one.
No lifter ever hit this. It was caught in review, on this branch,
and fixed before anything shipped. The uncomfortable part is that
review is what caught it. There was a test covering the seeding,
and it passed, because it ran in a target where
Int is 64 bits. A green test told us the code was
fine on an architecture the test could not reach.
What happens in Musklr when you log a set with no iPhone in range?
You tap, and the wrist confirms: a checkmark, a success haptic, and the numbers on the screen in front of you. None of that waits on the phone, because none of it is the phone's to say. You did the set, and the wrist is showing you the thing you just did.
Behind that, the command goes into a queue under its sequence number and keeps that number however many times it has to be offered again. Once the phone is reachable the queue drains in order, each command applies exactly once, the phone writes the sets to the database it owns, and its next snapshot carries the acknowledgement back. A command that turns up twice is dropped rather than lifted twice.
That is what the protocol is built to do, and what the tests pin. It is not something anybody has watched happening between two real devices, and that distinction is the honest end of this post.
The unit tests are specific: the acknowledgement boundary from
both sides, buffering a command that arrives early, filling a gap
partly and then fully, retransmitting under the original number,
resynchronising across a relaunch, a refused command consuming its
sequence. What none of them exercises is the wire.
WCSession never reports the Watch app as installed on
a paired simulator, so the phone's push gate refuses every push,
correctly, and nothing this branch put on the wire has run between
two devices at all.
Two of the cases a simulator structurally cannot produce, having no radio to go out of range of, are the ones this section is about: a duplicate set completion with the phone away, and a multi-exercise session with the phone genuinely out of reach.
A protocol is a set of rules, and rules are the kind of thing a unit test is good at. Two devices talking to each other is not. The on-device verification is next, and whatever is still wrong is in there.
Frequently asked questions
Can a set logged on an Apple Watch be lost before the iPhone records it?
The protocol is built so that it cannot be quietly dropped. Every command from the wrist carries a sequence number, the phone acknowledges the next number it expects rather than the last one it applied, and a command the acknowledgement is still naming gets resent under its original number until it lands. That is the guarantee TCP gives a byte stream, applied to taps. Those rules are proven by unit tests today, not by two devices in a gym.
Does WCSession.sendMessage deliver messages in the
order they were sent?
No. WCSession.sendMessage offers no ordering
guarantee, so anything whose effect depends on what came before it
needs ordering of its own. Musklr numbers every command, buffers
one that arrives early instead of applying it, and drains the
buffer in one run once the missing command turns up.
How do you stop a retransmitted command from logging the same set twice?
By having the receiver drop anything numbered below what it is waiting for, so a resent command that did arrive the first time is discarded rather than applied again. Duplicate rejection is what makes retransmission safe to attempt, and without it one retry logs a second set that nobody lifted.
Why does the iPhone own the workout database instead of the Apple Watch?
Because two writers need conflict resolution and one writer does not. The Watch never writes to the workout database. It sends commands and renders what it is sent, and the phone stores every set. It does keep two small pieces of its own state, the highest sequence number it has sent and a HealthKit workout session, and neither is your training log.
Does syncing a workout between an Apple Watch and an iPhone in Musklr require Musklr Pro?
No. Logging from the wrist and syncing with the phone is part of the free app, it works offline, and no account is needed. Musklr is free to use for as long as you like. Pro is optional, about $2 a month, and it covers extras like unlimited routines and cloud sync rather than access to the Watch.
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.