A companion app for connected hardware has two connectivity dependencies most web apps don't: a link to the internet/backend, and a link to the device itself (often BLE). Either one can be down independently, and a design that assumes both are always available breaks in exactly the situations users most want it to work — no signal in a basement where a smart-home hub lives, a device out of Bluetooth range, a car with no cell coverage. Offline-first design treats these as expected states, not edge cases.
Local-first data, synced when possible
The core pattern is storing data locally on the device (in a local database, not just in-memory state) as the source of truth for what the app displays, with backend sync happening asynchronously in the background rather than being a precondition for the UI to function. The app reads from and writes to local storage immediately, and a sync layer reconciles local and remote state whenever connectivity allows — the user experience doesn't stall waiting for a network round trip for every interaction.
Queueing actions taken while offline
Actions a user takes while offline — changing a setting, triggering a command — shouldn't fail outright; they should queue locally and execute once connectivity (to the device, the backend, or both) returns. This requires the app to track pending actions explicitly, show the user their queued/pending state honestly (not silently pretend the action succeeded), and handle the case where a queued action is no longer valid by the time it executes — a command queued for a device that's since been removed from the account, for instance.
Conflict resolution when local and remote state diverge
If a user changes a setting locally while offline, and the device or backend state changes independently in the meantime (another user made a change, the device's actual state changed), the app needs a defined way to reconcile the two when sync resumes — not an undefined behavior that surprises the user. Common approaches include last-write-wins with a visible timestamp, explicit conflict prompts for changes that can't be automatically reconciled, or domain-specific merge logic where it's clear which value should win (device-reported sensor readings should generally not be overwritten by a stale local cache, for instance).
The choice of cross-platform or native for the app shell doesn't change these requirements — offline queueing and conflict resolution have to be designed the same way regardless — though native gives more direct control over exactly when background sync is allowed to run.
Distinguishing "no device connection" from "no internet"
These are different failure states that call for different UI treatment and, often, different recovery actions — "can't reach your device, check it's powered on and in range" versus "can't reach our servers, some features may be limited." Collapsing both into a generic "connection error" message forces the user to guess which problem they're actually facing and how to fix it. Distinct, accurate error states are a small design decision with an outsized effect on how trustworthy the app feels when something's wrong.
What should still work fully offline
- Viewing previously-synced data (device history, settings, past readings)
- Direct BLE interactions with a device in range, even with no internet at all
- Queueing configuration changes for later sync
- Core device control that doesn't require cloud round-trips (many BLE-direct commands don't)
How we approach this
We design the local-data and sync layer as a first-class part of the app architecture, not an add-on handled after the "happy path" UI is built. See our mobile & web app work for how this fits into building companion apps for connected hardware. On the backend side, the cloud representation an offline app is ultimately syncing against is often a device shadow — the reported/desired-state split there is designed for exactly this kind of intermittently-connected client.
Our AI-Personalized Sleep Coach case study covers an offline-first companion app built this way.
Related reading