# Case Study: ESP-Point — IoT Hardware + iOS Sync, Offline-First Architecture
Problem: Bidirectional realtime sync between an ESP32 microcontroller (DIY IoT device) and an iOS app: the device uploads sensor data, and the app sends commands + firmware updates to the device. Offline-first is mandatory — the device is used in locations without internet access.
Result: 50ms BLE handshake median, 3-day offline survival (local cache + delayed sync), OTA firmware update with a 99.2% success rate, 12K devices sold.
1. Architecture
1[ESP32 Device]2 ↓ BLE (peripheral)3[iOS App — central]4 ↓ Wi-Fi when available5[Firebase Realtime DB + Cloud Storage]6 ↓ sync queue7[Web Dashboard for fleet ops]The iOS app acts as a proxy / router: a BLE \<-\> Cloud bridge.
2. BLE State Machine: The Real Issue
BLE doesn't have just one "easy demo." State machine fragmentation is the real production issue:
States:
unpairedScanningpairingpaired(connected)paired-disconnected(cached)firmwareUpdatingerror(reachable from every state)
For each transition, we resolved 2-3 race conditions in iOS CBCentralManager callbacks:
didDisconnect+didFailToConnectoverlap — UUID-based dedupscanForPeripheralsbackground restoration — setCBCentralManagerOptionRestoreIdentifierKey- On the Bluetooth state transition
.poweredOff→.poweredOn, scanning doesn't auto-resume — explicit re-call toscanForPeripherals
Result: 50ms handshake median. Connection drop rate 0.8% (industry standard 4-6%).
3. Offline Storage: Realm + Sync Queue
3-day offline target. Every sensor reading (hourly) is written to device storage, then pushed to a sync queue in Realm:
1class SyncQueue: Object {2 @Persisted(primaryKey: true) var id: ObjectId3 @Persisted var payload: Data4 @Persisted var createdAt: Date5 @Persisted var retryCount: Int = 06 @Persisted var lastError: String?7}Sync worker:
1final class SyncWorker {2 func runOnce() async {3 let realm = try await Realm()4 let pending = realm.objects(SyncQueue.self)5 .where { $0.retryCount < 5 }6 .sorted(by: \.createdAt)7 .prefix(50)8 9 for item in pending {10 do {11 try await uploadToFirebase(item.payload)12 try realm.write { realm.delete(item) }13 } catch {14 try realm.write {15 item.retryCount += 116 item.lastError = error.localizedDescription17 }18 }19 }20 }21}The sync worker is triggered via NWPathMonitor network reachability. In the background, the app retries via BGAppRefreshTask starting day 1.
4. OTA Firmware Update
8-12MB binary blob transfer over BLE:
- Bluetooth chunk size: 244 bytes (BLE 5 max safe)
- Sequence number + CRC32 per chunk
- Acknowledgment per 10-chunk batch
- Total transfer: 4-6 minutes (for 10MB firmware)
- Resume on disconnect support
Success rate: 99.2%. Failure modes:
- 0.5%: connection drop right before the last chunk
- 0.2%: device flash corruption (re-upload OK)
- 0.1%: power loss at a critical moment (manual recovery)
The OTA UI matters — progress bar + "do not close app" warning + battery indicator. +18% satisfaction in App Store reviews.
5. Test Strategy
- Unit tests: Sync queue state machine, BLE manager state transitions (mocked CBCentralManager)
- Integration tests: Realm migration, sync conflict resolution
- Device farm: 6 physical ESP32 units + 4 iPhone devices (iOS 16-18) — physical testing in the CI pipeline
- Snapshot tests: UI state for each BLE state
Coverage: Domain layer 92%, BLE manager 78%, UI 65%. Total 78%.
6. iOS App Architecture
- SwiftUI iOS 16+
- BLEManager:
@MainActoractor (Bluetooth callbacks run on the main thread) - DataLayer: Realm primary + Firebase secondary (offline-first)
- Sync orchestrator: BG task + foreground refresh
Memory baseline: 105MB. Peak (during OTA): 220MB (firmware buffer).
7. Lessons
1. Physical BLE testing is mandatory. The simulator can't simulate BLE. A physical device in CI is a must.
2. `CBCentralManagerOptionRestoreIdentifierKey` is critical for background relaunch — without it, the connection is lost after the app is killed.
3. State machine documentation. Every transition of the 6-state machine is logged for production debugging.
4. Realm migration empties the sync queue on schema changes (this wasn't validated with a test → 200 users lost data).
5. Don't give users an OTA "abort" button — risk of flash corruption. Pause/resume only.
6. Firebase rules can throttle offline write throughput — requireAuth: false write rule + custom token validation.
7. Battery profiling. Constant BLE scanning = 8 hours of battery life. Connection-on-demand pattern saves 35%.
Result: IoT + iOS sync is 10x harder than the "happy path" demos suggest. State machine + offline storage + OTA = 3 months of technical debt if not planned upfront.
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
We respect your privacy. You can unsubscribe at any time.

