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

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

We respect your privacy. You can unsubscribe at any time.

Share

Related Articles

iOS

SwiftData in Production: 6 Months of Real-World Experience and 3 Scenarios That Sent Us Back to Core Data

Production experience with SwiftData on iOS 17+. Migration pitfalls coming from Core Data, the practical realities of concurrency, real performance baseline numbers, and why we went back to Core Data on some projects.

14 min read
iOS

Case Study: TahminApp — 500K Users, 99.9% Uptime, 6 Months in Production

Real-time prediction notifications and a scalable data pipeline. Firestore + Cloud Messaging + Cloudflare Workers serving 500K MAU, p99 latency of 180ms, 99.94% uptime — and the real bottleneck was not where we expected it.

12 min read
iOS

Case Study: MADPAW — Pet Tracker, GPS + Activity ML, 30-Day Battery Life

GPS tracker + activity classification, targeting 30+ days of battery life. Low-power location, on-device ML, scheduled fix patterns. 32-day test passed, 95% activity accuracy, 12K devices.

12 min read
iOS

Swift 6.2 and Post-WWDC26: Is Concurrency Really "Approachable" Now?

How default actor isolation, the @concurrent attribute, and nonisolated(nonsending) from SE-0461 and SE-0466 tore down the Swift 6 strict concurrency wall — and what to watch for in a production migration.

10 min read
iOS

iOS 26 Liquid Glass: A Guide to Adapting Your SwiftUI App to the New Material System

From glassEffect APIs to tabBarMinimizeBehavior, from the UIDesignRequiresCompatibility opt-out to GlassEffectContainer performance traps — the real decisions I faced migrating a production SwiftUI codebase to iOS 26's Liquid Glass language.

13 min read