Designing apps for devices that lose connection
Devices drop off the network, and the software has to stay honest while they are gone. How that shapes the data model, the UI, and the ingestion path.
A connected device spends a meaningful part of its life unreachable. It sits behind a concrete wall, in a basement, in a parking garage, on a mobile network that drops packets for ninety seconds at a time, or on a router that reboots every night at three. The hardware is fine. The link is not.
The usual software model assumes the link is always there. The app fetches state, the dashboard renders it, the toggle sends a command and turns green. That model works on a desk in the office and starts failing the week the product reaches real installations.
We build the software layer of connected products: the mobile app, the telemetry backend, the dashboards, the BLE and MQTT plumbing. Not the boards, not the firmware. What follows is about the assumptions on that side that break first, and what to put in their place.
Last known state belongs in the data model
The first instinct is to treat connectivity as a UI concern: fetch state, and if the fetch fails, show a spinner or an error. That pushes the problem to the wrong layer. The app now has no vocabulary for the most common situation in the product's life: the device is fine, but nobody has heard from it in eleven minutes.
Model it explicitly. A device record should carry, at minimum:
- the last reported value for each field
- the timestamp that value was received by the server
- the timestamp the device claims it was measured
- the current link status, derived from a heartbeat rather than from whether the last request succeeded
Once those exist as fields rather than as inferred state, every consumer gets the same answer. The app, the web dashboard, and the alerting rules all read the same last_seen_at and reach the same conclusion about whether to trust it. Without that, each surface invents its own staleness heuristic, and they disagree, which is how you end up with an app showing "offline" while the dashboard shows a comfortable green.
Staleness is not binary either. A thermostat reading from four minutes ago is useful. A door sensor reading from four minutes ago may be worthless. The freshness horizon is a property of the measurement, so store it with the measurement.
Optimistic UI is fine for text, dangerous for actuators
Optimistic updates are good practice in most product work. You render the change immediately, reconcile with the server afterwards, and the interface feels instant. If the write fails, you roll back and the user loses a little typing.
Physical actuators break the trade. If the toggle flips to "on" the moment the user taps it, the app has made a claim about the state of the world that it cannot support. The command may still be sitting in a queue. The device may be offline. The relay may have received the command and failed to close.
The rollback story is also worse. Reverting a text field is a minor annoyance. Reverting a lock, a heater, or a pump is a user who walked away believing the door was locked.
The alternative is not a slower UI. It is an honest one, with three visibly distinct states:
- requested: the command has been accepted by the backend
- confirmed: the device has reported the resulting state
- failed or unknown: no confirmation within the timeout
Users tolerate a pending state. What they do not tolerate is an interface that lied confidently. For anything with a physical consequence, the green state should mean the device said so.
Every command needs an id
Unreliable links produce retries, and retries produce duplicates. A command sent over a connection that drops before the acknowledgement arrives will be sent again, whether by the app, by the backend, or by the transport itself. MQTT's QoS 1, for instance, guarantees at-least-once delivery, which is precisely a guarantee that duplicates are possible.
So generate the command id on the client, at the moment of intent, and carry it end to end. The device keeps a short window of recently applied ids and ignores repeats. "Set brightness to 40" is naturally idempotent and survives this easily. "Increment by 10" does not, which is a good reason to prefer absolute commands over relative ones in the protocol design.
The queue needs a policy too. When a device reconnects after two hours, should it apply the four commands that accumulated, or only the last one? For a setpoint, replaying stale commands is wrong. Commands should carry an expiry, and the backend should collapse superseded ones rather than delivering a burst of contradictory instructions.
Device clocks are not a source of ordering
Battery-backed real-time clocks drift. Devices that lose power come back at the epoch, or at whatever the firmware defaults to. Timezone handling in embedded code is frequently wrong in ways nobody notices until a daylight-saving boundary.
If you order events by device timestamp, a single device with a bad clock can insert readings into last week, or into next year, and corrupt every chart and aggregate that touches them.
Keep both timestamps and be clear about their roles. Server receive time is trustworthy and useful for ordering and retention. Device time is what the device believes, useful for reconstructing sequence within a single buffered batch, and always suspect across devices. When a device reconnects, you can often correct its batch by anchoring the newest reading to arrival time and working backwards through its own relative offsets.
Also validate on ingest. A reading more than a small margin in the future is a clock problem, not data.
Reconnection is a burst, not a trickle
A well-built device buffers while offline and flushes on reconnect. That is the correct firmware behaviour, and it means your ingestion path's real load pattern is not the steady average you sized for.
After a regional network incident, a fleet does not come back gradually. It comes back together, each device dumping hours of buffered readings at once. Ingest that is comfortable at the steady-state rate can fall over at ten or a hundred times that rate for a few minutes.
Design for it: accept batched payloads rather than one request per reading, put a queue between ingest and processing so the write path absorbs the spike, and make the writes idempotent on a device-and-sequence key so a partially-acknowledged batch can be replayed safely. Rate limits should be generous on burst and firm on sustained throughput, or you will throttle exactly the devices trying to recover.
Say "stale", not nothing
The last piece is presentation. A dashboard that renders a five-hour-old value in the same style as a five-second-old value is not a neutral choice. It is an assertion that the data is current.
Show the age of the reading next to it. Change the treatment once it crosses the freshness horizon for that measurement. Distinguish "we have not heard from this device" from "this device reported zero", because those look identical on a chart and mean opposite things. Keep the last known value visible rather than blanking the tile. The old number is still the best available information, as long as it is labelled as old.
Alerting deserves the same care. Silence is ambiguous: it can mean nothing is wrong, or that the thing that would have told you is offline. Absence of telemetry should be its own alert, distinct from a threshold breach.
None of this is exotic. It is mostly the discipline of refusing to let the interface claim more certainty than the network can deliver. The devices will keep dropping off; the software is what decides whether that feels like a broken product or a normal Tuesday.
See IoT software.