Moving to native Swift
Our collar syncs activity data to the phone over Bluetooth LE. On 27 March we wrote a new Expo native module, modules/ble-collar, that uses CoreBluetooth directly. The native code handles scanning, connecting, syncing and background reconnects, so the JS bridge is no longer needed for BLE callbacks. When the collar disconnects, didDisconnectPeripheral re-issues connect() natively, which survives iOS app suspension and termination. The module emits events to JS, and a listener registered at app startup parses the data and posts it to the backend.
The same day we shipped a 3-tier sync protocol: the collar stores features, summaries and raw windows. The backend's SyncV3 endpoint classifies feature epochs via threshold rules and stores FIFO summaries in a new database table.
The timer problem
The first native version synced on a periodic Timer, with a 5-minute cooldown between automatic syncs to save collar battery. Timer-based periodic sync does not work in the iOS background, because the RunLoop is suspended.
On 29 March we switched to the standard BLE background pattern: connect, sync, upload, disconnect, then re-issue connect(). iOS keeps a pending connect active even when the app is suspended, so the collar's next advertisement triggers the reconnect and the next sync cycle. We removed the cooldown timer and the periodic timer; the collar's advertising interval now controlled how often we synced.
That needed a fix the same evening. Reconnecting straight after a sync-triggered disconnect ran into the cooldown, and the manager sat idle: a connect, cooldown-block, stay-idle deadlock. We delayed the reconnect until the 5-minute cooldown expired.
The heartbeat rewrite
The next day we rewrote the iOS BLE manager to stay connected to the collar instead of cycling between disconnect and reconnect.
We subscribed to the Beta Metrics characteristic, a 5-second heartbeat, to wake the app from suspension.
We also fixed data retention. Collar data is now cleared only after the backend confirms the upload, which prevents data loss. Failed uploads go to a disk-backed retry queue that holds at most 20 entries.
An AppDelegate subscriber ensures the CBCentralManager exists for state restoration. A cache of scanned peripherals fixes a silent connect failure on first pairing. After three failed connects, didFailToConnect clears the stale peripheral and scans fresh, and reconnects fall back from connect to scan when the collar's address changes.
What is still open
By 8 April the Swift BLE connector had grown to 870 lines of state machine, designed for the 3-tier protocol, while the firmware had already moved to raw windows only. It needs a clean rewrite: connect, accumulate 322-byte windows and batch upload them to the raw-windows endpoint.
If you are building something similar
- Timers do not fire in the iOS background. Use CoreBluetooth pending connects and state restoration instead.
- Use BLE notifications to wake your app. A periodic heartbeat characteristic gives the app a regular wake-up from suspension.
- Never clear device data before the server confirms receipt. Keep failed uploads in a retry queue.