Designing for the network you actually have
The cover image is the network your architecture diagram assumes. Your user is on a bus in Lagos traffic with two bars of signal, a data bundle they’re rationing until payday, and a phone that aggressively kills background work to save battery. I build consumer apps, including payments apps, for exactly that user, and this post is the engineering that falls out once you stop treating the first network as real and start designing for the second.
None of this is exotic. It’s ordinary engineering with honest assumptions, which is precisely why it’s rare.
Budget in round trips, not milliseconds
On a good connection, latency hides your sins. On a variable one, every round trip is a dice roll, and the p95 is where your product actually lives. The single highest-leverage exercise I know: take your most important user action and count the round trips between tap and outcome. Not the milliseconds, the trips.
Then collapse them. Fold the three pre-flight reads into one query. Piggyback state the client will need onto the response it’s already getting. Keep connections warm so the handshake tax (DNS, TCP, TLS, sometimes all three on a cold radio) is paid before the moment that matters rather than during it. On a flaky network, one round trip that does more always beats three elegant ones, because each extra trip is another chance for the dice to come up wrong. The fastest request is the one you didn’t make; the second fastest is the one whose connection already existed.
The retry rule that actually matters
Everything downstream of a bad network depends on getting one distinction right: a failure to connect is not the same as a timeout after sending.
A connect failure is safe to retry; your request never arrived, and nothing happened. A timeout after the request went out is an unknown outcome. The server may have done the work and the network ate the answer. On an unreliable network this stops being an edge case and becomes a daily event, and if the request was “charge this person,” a blind retry is how someone gets billed twice for one bus fare.
So the rule is: retry connect errors freely, never blindly retry a sent request whose answer you lost. (This rule earned its own post.) For anything with side effects, the client attaches an idempotency key so a repeat is a no-op, or it stops retrying and asks the server what actually happened. Reconciliation, not repetition. Every mutating endpoint gets designed with the question “what happens when I’m called twice?” answered in writing, because on this network, you will be.
Assume the process dies mid-flow
Aggressive OS battery management plus flaky radios means your app will be killed mid-upload, mid-payment, mid-anything. The design answer is durable local queues: the user’s action is committed to disk on-device first, then drained to the network by a worker that retries with backoff, survives restarts, and reports per-item status honestly.
I built Aevu’s media uploads this way (a persistent per-asset queue, replayable completion, progress that survives process death) and the same shape applies to any consumer action that matters. The user’s tap is the transaction. The network transfer is merely its eventual settlement, and the app should be able to lose power between the two without losing the truth.
Payload discipline is a feature
Data in much of Africa is bought in bundles and rationed. Every megabyte your app wastes is money your user spent on your logo animation. This reframes payload work from performance tuning to basic respect:
- Images sized to the screen requesting them, not shipped at upload resolution and scaled in CSS.
- Video at adaptive bitrates, so two bars of signal gets a watchable stream instead of a stalled beautiful one.
- APIs that return what the screen needs, with caching headers tuned so nothing is fetched twice.
- The app functional, not just installed, on low-end Android devices, because that’s the median phone.
A skeleton screen that resolves in reasonable time on 3G does more for trust than any redesign. Perceived speed is mostly honesty about progress.
Tell the truth about pending
Here’s where network engineering becomes product design. When the rail is slow, the worst thing your UI can do is lie with a spinner, because users interpret an endless spinner as failure and do the one thing that makes everything worse: retry by hand.
The honest pattern is optimistic UI with explicit pending states. Accept the action instantly, show it as processing, and let a reconciliation loop settle it to confirmed or failed, with the failure path actually designed: what the user sees, whether money moved, what happens next. “This is taking longer than usual, we’ll notify you when it completes” keeps users calm through a slow settlement. A spinner that dies into a generic error creates a double payment attempt and a support ticket. Under unreliable rails, truthful pending states are not UX polish; they’re part of the correctness story, because they’re what stops humans from becoming the retry storm.
Test on the network you claim to serve
None of this survives if the team only ever runs the app on office wifi. The practices that keep you honest are cheap: develop with a network conditioner on, make “kill the app mid-flow, reopen, verify nothing was lost or doubled” a standard test case, watch p95 and timeout rates from real devices rather than a synthetic monitor in a data centre, and occasionally spend a day using your own app on a deliberately terrible connection. It’s a humbling and extremely productive day.
The punchline
Designing for a bad network produces a better app on every network. The idempotency keys prevent double-charges on fibre too. The durable queue makes the good-network experience instant. The payload discipline makes the app feel fast everywhere. The honest pending states build trust with everyone.
The network in the architecture diagram is a special case, and a rare one globally. Design for the bus.
No account, no tracking. One vote per reader.