All Articles
CategoryiOS
Reading Time
13 min read
Published
2026-05-13
Word Count
732words

Grab a coffee — this one is a deep dive!

Case Study: ESP-Point — IoT Hardware + iOS Sync, Offline-First Architecture

Summary

ESP32 device ↔ iOS app bidirectional sync, BLE handshake + Firestore + Realm offline cache. 50ms handshake, 3-day offline survival, OTA firmware update. BLE state machine fragmentation is the real story.

  • 50ms BLE handshake median, connection drop rate 0.8% (industry 4-6%)
  • 3-day offline target met with Realm sync queue + Firebase
  • OTA firmware update, 244-byte chunks, 99.2% success rate, takes 4-6 minutes
  • Without CBCentralManagerOptionRestoreIdentifierKey, the connection is lost after the app is killed
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

swift
1[ESP32 Device]
2 ↓ BLE (peripheral)
3[iOS App — central]
4 ↓ Wi-Fi when available
5[Firebase Realtime DB + Cloud Storage]
6 ↓ sync queue
7[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:

  • unpairedScanning
  • pairing
  • paired (connected)
  • paired-disconnected (cached)
  • firmwareUpdating
  • error (reachable from every state)

For each transition, we resolved 2-3 race conditions in iOS CBCentralManager callbacks:

  1. didDisconnect + didFailToConnect overlap — UUID-based dedup
  2. scanForPeripherals background restoration — set CBCentralManagerOptionRestoreIdentifierKey
  3. On the Bluetooth state transition .poweredOff → .poweredOn, scanning doesn't auto-resume — explicit re-call to scanForPeripherals

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:

swift
1class SyncQueue: Object {
2 @Persisted(primaryKey: true) var id: ObjectId
3 @Persisted var payload: Data
4 @Persisted var createdAt: Date
5 @Persisted var retryCount: Int = 0
6 @Persisted var lastError: String?
7}

Sync worker:

swift
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 += 1
16 item.lastError = error.localizedDescription
17 }
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: @MainActor actor (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

#case study#IoT#BLE#Bluetooth#offline-first#Realm#Firebase
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy

Share