For years, South African software got a free chaos test every day. The power went off on a schedule, cell towers ran down their batteries, and Wi-Fi routers went dark mid-request. Eskom marked 300 days without load-shedding in March 2026, but outages and patchy connections haven't gone anywhere. The habits load-shedding forced on us are still the right defaults.
The request that never came back
The most dangerous moment in any app is a write that times out. The user tapped Pay. The request left the phone. Then the router lost power. Did the payment go through? The app doesn't know, the user doesn't know, and the most natural thing in the world is to tap again.
I've built payment flows across four gateways in South Africa and Zambia, including mobile money, where the network between the user and the money is never guaranteed. The lesson is always the same: design every write so it is safe to run twice.
Idempotency keys: one tap, one payment
An idempotency key is a unique value the client generates once per action and sends with the request. The server stores the key with the result. If the same key arrives again, the server returns the stored result instead of doing the work a second time.
// Generated once when the user starts checkout, reused on every retry of that checkout.
const idempotencyKey = crypto.randomUUID();
await fetch("/api/payments", {
method: "POST",
headers: { "Content-Type": "application/json", "Idempotency-Key": idempotencyKey },
body: JSON.stringify({ orderId, amount }),
});
Stripe's API works this way, and the IETF has a draft standard for the Idempotency-Key header. The key detail is
where the key is created: on the client, before the first attempt. A key generated on each retry is useless.
On the server, the rule is: check the key, do the work, store the result, all inside one transaction or with a unique constraint on the key. Two retries that arrive at the same millisecond should still produce one payment.
Offline-first: queue it on the device
For anything that isn't money, failing the user is usually worse than waiting. If a field worker logs a job card at a site with no signal, the app should save it locally and sync later, not show an error and lose the work.
The pattern:
- Write the change to local storage (IndexedDB in the browser) and show it in the UI straight away.
- Put it in an outbox queue, each item with its own idempotency key.
- Send the queue when the connection comes back, and remove each item only once the server confirms it.
The browser's Background Synchronization API can send the queue even after the tab is closed, but it isn't supported everywhere. Treat it as a bonus and always flush the outbox when the app next opens.
Honest status beats a spinner
During an outage, a spinner that never stops is a lie. People tap again, refresh, or give up. Show what you actually know:
- Saved on this device, will send when you're back online.
- Sending… with a real timeout, then a clear failure state.
- Payment pending: we'll confirm in a few minutes. Then make sure you do, by polling or a webhook.
Pending is a real state. Model it in the database, not just the UI.
Retry like you mean it
Automatic retries should back off. Wait a little longer after each failure and add some randomness (jitter), so a thousand phones coming back online when the power returns don't all hit the server in the same second.
Why this still matters
Load-shedding made these patterns urgent, but they're just good engineering. Mobile networks drop, laptops sleep, servers restart during deploys. An app that survives the dark survives everything else too.










