Building a Universal Workout App: Seamless iPhone Apple Watch Data  Sync

Building a Universal Workout App: Seamless iPhone Apple Watch Data Sync

author: Wesley Matlock read_time: 4 —

How PushTo100 syncs workouts without dropping a single rep

🚦 Starting Line: One App, Two Devices, No Excuses

I didn’t set out to build a sync engine. I set out to build PushTo100 — a pushup tracker that lets you hit your rep goals no matter where you’re working out. In bed. At the park. Or, more realistically, on the living room floor in between meetings.

But as soon as I wanted it to work on both iPhone and Apple Watch, that innocent idea grew into something… harder. Syncing active workout sessions between devices — while offline, mid-rep, and with zero data loss — meant solving a full-on distributed system problem.

Turns out, this challenge shows up in interviews all the time too:
“How would you handle bidirectional sync between devices when one is offline?”
Most developers freeze. I built the answer.

🫶 Quick thing before we keep going: If this post helps you ship cleaner code or rethink your SwiftUI approach, tapping that 👏 button (up to 50 times!) helps more iOS devs find it. Appreciate you being here.

Here’s how it works.

🧱 Shared Swift Package, Shared Truth

At the core of PushTo100 is a Swift Package called PushupCore. It holds everything from models to sync logic and lives in both the iPhone and watchOS targets.

Workout Model

@Observable  
public class WorkoutSession: Codable, Identifiable {
public let id: UUID
public var startDate: Date
public var endDate: Date?
public var sets: [WorkoutSet]
public var isActive: Bool
public var source: DeviceSource
public var syncStatus: SyncStatus
public var lastModified: Date?
}

Why @Observable? Because it allows the UI on both platforms to react instantly to changes. SwiftUI binds directly to this model. No extra glue code.

The SyncStatus lets us mark whether a workout is .pending, .synced, or .conflict. That’s what powers smart retries.

🤀 WatchConnectivity: Friend and Foe

Apple’s WatchConnectivity works… until it doesn’t. So I wrapped it in its own manager, WatchConnectivityManager, and did three things:

  1. Queued all outgoing messages if the watch wasn’t reachable.
  2. Serialized every workout using JSONEncoder, and validated on decode.
  3. Structured every message with a type, payload, and timestamp.

Example Outbound Payload

let message: [String: Any] = [
"type": "workout_update",
"data": try JSONEncoder().encode(workout),
"timestamp": Date().timeIntervalSince1970
]

The iPhone and watch both implement WCSessionDelegate, listening for messages and decoding them back into WorkoutSession objects.

I also added reachability tracking:

func sessionReachabilityDidChange(_ session: WCSession) {
isReachable = session.isReachable
if isReachable { await syncPendingWorkouts() }
}

✅ Smart Sync with Retry Logic

I built SmartSyncManager to handle queuing, retries, and backoff. It’s like a mini dispatcher for unreliable connectivity.

  • It keeps a FIFO queue of unsynced workouts.
  • It retries with exponential backoff: 2s, 4s, 8s.
  • After 3 fails, it marks the workout as .failed.

Excerpt

while retryCount < maxRetries {
do {
await connectivityManager.syncWorkout(workout)
return
} catch {
retryCount += 1
try? await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(retryCount)) * 1_000_000_000))
}
}

❌ Offline Mode: The Watch Goes Solo

You can start a workout on the watch while it’s completely disconnected from your phone. That’s by design.

The watch app saves all workout data locally. Each time a set is added, it calls:

try await repository.updateWorkout(workout)

If the phone comes back online later, I sync everything automatically. No loading spinners. Just data integrity.

⚔️ Conflict Resolution: Merge Like a Pro

Sync isn’t just about delivery. It’s about agreement. When both devices update the same workout, I resolve it.

I use a ConflictResolver with logic like:

  • Prefer the one with more sets
  • If timestamps are close, merge
  • If one is clearly newer, accept it

I compare each set’s timestamp and reps to avoid near-duplicates.

🎙️ Crown Interactions

The watch uses digitalCrownRotation to increment reps. I enhanced this with a custom modifier smartCrownInput that plays haptics on milestones:

.digitalCrownRotation(
$currentReps,
from: 0,
through: 100,
by: 1,
sensitivity: .medium,
isHapticFeedbackEnabled: true
)

Every 5 reps? You feel it. Every set? You know it’s done.

🔧 Instruments: Sync Performance

I used Instruments + os_signpost to track sync time, queue size, and payload size.watchOS

What I learned:

  • sendMessage fails silently if payload > 15kB
  • Avoid full payloads unless needed. Deltas save time
  • Don’t block the main thread while waiting for replies

Signpost Example

let signpostID = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: "Sync", signpostID: signpostID)
// sync logic
os_signpost(.end, log: log, name: "Sync", signpostID: signpostID)

🔮 Swift Testing: I Broke It So You Don’t Have To

I mocked WatchConnectivityManager with control over connection state and delay.

Example:

let workout = WorkoutSession()
try await repo.saveWorkout(workout)
#expect(workout.syncStatus == .pending)
mockManager.simulateConnectivityChange(isReachable: true)
await mockManager.syncWorkout(workout)
#expect(workout.syncStatus == .synced)

This lets us test:

  • Offline creation
  • Retry timing
  • Conflict resolution logic

🎉 The Finish

The result? A fitness app where you can start on your Watch, finish on your phone, and never worry about a single rep going missing.

PushTo100 does the work behind the scenes — so you can focus on hitting your numbers, not syncing your devices.

If you’re hungry for more tips, tricks, and a few battle-tested stories from the trenches of native mobile development, swing by my collection of articles:
👉 https://medium.com/@wesleymatlock

These posts are packed with real-world solutions, some laughs, and the kind of knowledge that’s saved me from a few late-night debugging sessions. Let’s keep building apps that rock — and if you’ve got questions or stories of your own, drop me a line. I’d love to hear from you.


Originally published on Medium