Skip to content

How do you build a sync protocol between an Apple Watch and an iPhone?

Part II of a Musklr engineering devlog, on the 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:

Each part of Gabriel Gambetta's client-side prediction model and the part of Musklr that plays it
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 wrist confirms at the same moment whether the phone is fast, slow or absent Three timelines, one for each thing that can be true about the phone. In the first the phone answers in milliseconds, in the second it takes seconds, in the third it is not reachable at all. In every one of them the tap on the Watch is followed immediately by a confirmation on the wrist, at the identical point on the timeline, marked by a vertical line running through all three. What differs is only what happens afterwards: in the first the command reaches the phone and an acknowledgement rides the next snapshot back within milliseconds, in the second the same exchange takes seconds, and in the third the command is queued under its sequence number and nothing comes back at all until the phone is reachable again. The confirmation on the wrist never waits for any of it, because it is a prediction rather than a report. the wrist confirms here, in all three Phone answers in milliseconds wrist phone tap command out ack, on the next snapshot Phone answers seconds later wrist phone seconds Phone not reachable at all wrist phone away queued, and it keeps its number
The tap and the confirmation on the wrist land at the same moment in all three cases, because the confirmation is a prediction rather than a report. Only what happens after it changes: an acknowledgement back in milliseconds, the same exchange stretched over seconds, or a command queued under its sequence number with nothing coming back at all.

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.

Dialling 47 kg and 7 reps on the wrist in Musklr, and the watch answering the tap with a personal record.

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 does

Where 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.

An inclusive acknowledgement reports progress, an exclusive one names the missing command The same five commands, numbered 40 to 44, arrive at the phone with command 42 lost on the way, so 43 and 44 are buffered behind the gap. The sequence is drawn twice. In the upper row the phone sends back an inclusive watermark, the last number it applied, which is 41; a bracket under commands 40 and 41 marks how far it has got, and a question mark sits over the gap because that value is compatible with any number of missing commands and identifies none of them. In the lower row the phone sends back TCP's exclusive acknowledgement, the next number it expects, which is 42; an arrow points straight at the missing command, because naming what you are waiting for is the same thing as naming what was lost. The two rows differ only in which number is sent back, and that one change turns a report of progress into an identification of the gap. WHAT WE BUILT FIRST, INCLUSIVE the phone echoes the last number it applied ? 40 41 42 43 44 applied applied lost buffered buffered ack = 41 it proves progress, and it never names 42 WHAT TCP DOES, EXCLUSIVE the phone names the next number it expects 40 41 42 43 44 applied applied lost buffered buffered ack = 42
The same five commands with 42 lost on the way. The inclusive watermark on top can only say it applied up to 41, which is true of a great many situations and points at none of them. TCP's exclusive acknowledgement below names 42, the one thing it is waiting for, which is the same thing as naming what went missing.

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:

The Watch-to-phone command channel and the phone-to-Watch snapshot channel compared by what they carry, what losing a message costs, and what each one needs
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 arrive as a numbered run, snapshots replace one another Two channels side by side. The one from the Watch to the phone carries commands, an event stream, drawn as a run of separately numbered messages, 41 then 42 then 43, every one of which has to arrive. The one from the phone to the Watch carries snapshots, full state, drawn as a single revision giving way to the next, revision 12 replaced by revision 13, because the newest one replaces the last. WATCH TO PHONE commands, an event stream 41 42 43 every one of them must arrive PHONE TO WATCH snapshots, full state rev12 rev13 the newest one replaces the last
Two channels, two jobs. Commands are events and every one of them has to arrive, so they get the whole TCP apparatus. Snapshots are state and each one replaces the last, so they get a revision number and nothing else. Keeping them apart is what stops one quietly acquiring a requirement the other never had.

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 does

Why 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.

Download Musklr on the App Store

See everything Musklr does on the home page →

Share this post