Don't count time, anchor to it: one clock for a phone-and-Watch run
A run/walk timer that counts ticks drifts the moment the app is suspended — and two devices counting independently drift from each other. RunWalk keeps its clock correct across crashes, relaunches, and a Watch reconnecting mid-run by deriving everything from a single broadcast fact: the start time.
Last time the job was keeping RunWalk's coach alive and talking with the screen off. This one is harder and quieter: keeping the clock correct. Correct across the app being suspended, killed and relaunched, and — the real boss fight — a phone and an Apple Watch that each show the same run and must never disagree.
The bug with counting ticks
The obvious way to build an interval timer is to count: hold elapsed, add dt on every tick, compare against the schedule. It passes every test on your desk. Then it meets reality:
- iOS suspends the app in your pocket and the ticks stop. Wake it up and it's behind by however long it slept.
- The phone and the Watch each run their own counter. They start together, then drift — a dropped tick here, a scheduling hiccup there — until the phone says “Walk, 0:12” and the Watch says “Walk, 0:09.”
Our first design made this worse by trying to patch it: a “brain” counter on one device and a mirror “shadow” counter on the other, reconciled only at phase transitions. Between transitions they drifted; at each boundary they'd snap. It was two clocks pretending to be one.
The fix: one fact, derived everywhere
Stop storing elapsed time. Store the one thing that doesn't change — when the run started — and derive everything else, every frame, as a pure function of the wall clock:
elapsed = now − startDate − accumulatedPause
That's the whole idea. We call the broadcast fact the anchor:
struct RunAnchor: Codable {
var startDate: Double // t=0 of the workout clock (epoch seconds)
var accumulatedPause: Double // paused seconds folded in
var pausedAt: Double? // set while paused; nil = running
// …week, day, revision, healthOwner
}
// Elapsed workout time at wall-clock `now`. Frozen while paused.
func elapsed(now: Double) -> Double {
let reference = pausedAt ?? now
return max(0, reference - startDate - accumulatedPause)
}The current phase, the seconds left, the progress ring — none of it is stored. It's computed from elapsed against the static Couch-to-5K schedule, which is a pure walk down a list:
// Both the phone and the Watch call this with elapsed() from the SAME anchor,
// so they always compute identical numbers. No reconciliation, no drift.
func derive(elapsedSeconds: Double) -> DerivedRunState {
var acc = 0.0
for (i, interval) in intervals.enumerated() {
let end = acc + Double(interval.seconds)
if elapsedSeconds < end {
return DerivedRunState(
index: i, phase: interval.phase,
intervalRemaining: Int(ceil(end - elapsedSeconds)),
progress: elapsedSeconds / Double(totalSeconds))
}
acc = end
}
return .finished
}Why it can't drift
Two devices that compute the same function of the same inputs get the same answer. The phone and the Watch share one anchor and one wall clock, so their displays are equal by construction — not reconciled, equal. There's no shadow timer to keep in sync because there's no second clock. The only synchronization is broadcasting one small struct over WatchConnectivity when it changes.
Everything that used to be hard falls out of this for free:
- Suspended in your pocket. Nothing to catch up. On wake,
nowis bigger, soelapsedis bigger, and the nextderiveis simply correct. - Killed and relaunched. The app reloads the anchor and is instantly right — its age is implicit in
startDate. There's no “restore my counter to where it was” step to get wrong. - A Watch that reconnects three intervals late. It adopts the anchor and snaps straight to the correct phase. No replay, no fast-forward.
- Pause is just bookkeeping: stamp
pausedAtsoelapsedfreezes, and on resume fold the gap intoaccumulatedPause.
When both devices press Start
The one genuinely ambiguous case is two devices minting a run at once — you tap Start on the phone and the Watch before they've synced. Two anchors, one run. So the anchor carries a revision and its own runID, and there's a total order for picking a winner:
// Same run → newest revision wins. Different runs (both devices hit Start)
// → the later-started one wins, so they converge instead of flapping.
func supersededBy(_ other: RunAnchor) -> Bool {
if other.runID == runID { return other.revision > revision }
return other.startDate > startDate
}Deterministic, so both sides independently pick the same survivor and converge on one run. The anchor also names a healthOwner — phone or Watch — so exactly one device writes the finished workout to Apple Health and you don't get two copies of the same run.
The quiet dividend
Because the schedule logic takes time as an argument instead of reading a clock, it has no Timer, no iOS dependency, and no notion of “now” of its own. That makes the entire interval engine a pure state machine you can unit-test on any platform — feed it elapsed seconds, assert the phase — which is how you actually gain confidence that a run is correct without lacing up and going outside for thirty minutes.
The lesson generalizes past fitness apps: whenever two clients have to agree on “where are we now,” don't sync their counters — give them one timestamp and a pure function. The wall clock is the only clock you can trust, and it's already perfectly synchronized for you.