When the signal drops: building for the network people actually have.
Most of our users open our apps on a weak mobile signal, not office Wi-Fi. Here is how we stopped treating offline as an error and started building for the network people actually have.
Open almost any app on a fast office connection and it feels solid. The same app, opened in a minibus taxi somewhere between two towers, on a prepaid SIM with a few rand of data left, tells a different story. That second moment is the one most of our users actually live in, and for a long time it was the one we tested least. This is about what changed when we started treating a weak or absent network as the normal case rather than the exception.
The network in the demo is not the network in the taxi
Every app looks reliable on the machine that built it. The server is a few milliseconds away, the Wi-Fi never flinches, and a request either succeeds instantly or fails loudly enough to notice. None of that describes the conditions our apps run in once they leave us. Signal fades in a lift, drops behind a hill, and comes back three minutes later as if nothing happened. Data runs out mid-session. A request does not fail so much as hang, which is worse, because the app has to decide how long to keep hoping.
The honest version of reliability is not "the request worked." It is "the app did something sensible when the request could not." We started measuring ourselves against that second sentence, and a lot of quiet assumptions fell over.
Offline is a state, not an error
The first change was conceptual. For years our code treated a missing connection the way it treated a bug: an exception to catch, log, and apologise for with a red banner. But there is nothing exceptional about being offline on a mobile network. It is a state the app passes through many times a day, as ordinary as being paused or being in the background.
Once offline becomes a first-class state, the questions change. Not "how do we show an error?" but "what is still true and usable while we wait?" A feed the person already scrolled is still there to read. A draft they are typing has not gone anywhere. A tap they just made can be remembered and replayed the moment the signal returns. The app stops pretending the network is a wall and starts treating it as weather.
What we do before the request ever leaves the phone
Designing for real networks pushed a surprising amount of work to the device itself. If the phone can answer a question without asking the server, it should, and if it must ask, it should ask in a way that survives the ask failing. A few of the habits that came out of this:
- Read from a local cache first and treat the network response as a correction, not the source of truth. The screen fills immediately with what we last knew, then quietly updates if anything changed.
- Write optimistically. When someone posts, likes, or saves, the interface reflects it at once and the actual request queues behind the scenes, retrying on its own schedule until it lands.
- Make every write idempotent, so replaying a queued action after a reconnect can never double-count it. A like pressed once stays one like, however many times the retry fires.
- Back off politely. A failed request waits a little longer each time instead of hammering a network that is already struggling, and it stops hammering a battery that is already low.
- Tell the truth in the interface. A small, calm indicator that something is still syncing beats both a frozen spinner and a false sense that everything is done.
A good offline experience is mostly invisible. The person never learns the signal dropped, because nothing they were doing stopped working.
The shared layer that makes it boring
None of this is hard to do once. It is hard to do the same way in every app, every time, which is exactly the kind of problem we have learned to solve by building it once and sharing it. Caching, the write queue, the retry and back-off policy, the reconnect listener, and the little syncing indicator all live in a shared layer rather than being reinvented screen by screen. An app that sits on that layer gets sensible offline behaviour as a default, not as a feature someone remembered to add under deadline.
The payoff is consistency the user can feel without naming. A draft behaves the same whether it is a confession, a comment, or a profile edit. Recovery looks the same everywhere, so the app never surprises the person at the worst possible moment. And when we improve the policy, every app improves at once, which is the only way a small team keeps this kind of discipline affordable.
Honest about what we cannot do
Offline-first is not magic, and pretending otherwise would be its own kind of dishonesty. Some actions genuinely need the network to happen at all, and for those the right answer is a clear, patient queue and an honest status, not a fake success. Some data is too large or too fresh to hold on the device, and we are deliberate about what we keep and what we fetch. The goal was never to abolish the network. It was to stop letting a bad few seconds of it ruin an otherwise good experience.
What we gained is a different default posture. We no longer build for the connection we have at our desks and hope it generalises. We build for the connection a person actually has, standing in a queue, riding a taxi, somewhere the bars come and go, and we let the good office network be the pleasant surprise rather than the assumption. Reliability, it turns out, is less about the network being strong and more about the app being graceful when it is not.