On a laptop with fibre, the network feels reliable. On a phone, it isn't. People walk into lifts, switch between Wi-Fi and 4G, and background your app mid-request.
If your app assumes every request succeeds exactly once, you will eventually create duplicate orders, lose form data or show endless spinners. This post covers three patterns that fix most of these problems.
1. Retry with exponential backoff and jitter
Retrying immediately makes a struggling network worse. Wait longer after each failure, and add randomness so thousands of clients don't retry in sync.
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function withRetry<T>(
fn: () => Promise<T>,
{ retries = 4, baseMs = 400, maxMs = 8000 } = {}
): Promise<T> {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (err: any) {
const retryable = err.name === "TypeError" /* network failure */ ||
(err.status >= 500 && err.status < 600) || err.status === 429;
if (!retryable || attempt >= retries) throw err;
const exp = Math.min(maxMs, baseMs * 2 ** attempt);
const jitter = Math.random() * exp; // "full jitter"
await sleep(jitter);
attempt++;
}
}
}
Only retry errors that make sense: network failures, 429 and 5xx. Never retry a 400 or 401, because the result won't change.
2. Make writes safe to retry with idempotency keys
Retries create a new problem. If the request reached the server but the response was lost, a retry would create the order twice.
The fix is an idempotency key: the client generates a unique ID per user action and sends it with the request. The server stores the result for that key and returns the same response if it sees the key again.
Client:
export async function createOrder(order: Order) {
const key = crypto.randomUUID(); // generate once per user action
return withRetry(() =>
fetch("/api/orders", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": key, // same key on every retry
},
body: JSON.stringify(order),
}).then(async (res) => {
if (!res.ok) throw Object.assign(new Error("HTTP error"), { status: res.status });
return res.json();
})
);
}
Server (Express and Postgres sketch):
app.post("/api/orders", async (req, res) => {
const key = req.header("Idempotency-Key");
if (!key) return res.status(400).json({ error: "Idempotency-Key required" });
// Atomically claim the key. If it already exists, do nothing.
const claim = await db.query(
`INSERT INTO idempotency_keys (key, user_id) VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING RETURNING key`,
[key, req.user.id]
);
if (claim.rowCount === 0) {
const saved = await db.query(
"SELECT response FROM idempotency_keys WHERE key = $1", [key]);
if (saved.rows[0].response) return res.status(200).json(saved.rows[0].response);
return res.status(409).json({ error: "Request in progress" });
}
const order = await createOrderInDb(req.body, req.user.id);
await db.query("UPDATE idempotency_keys SET response = $2 WHERE key = $1",
[key, order]);
res.status(201).json(order);
});
In production, also scope keys per user, expire old ones, and store the request fingerprint so a reused key with a different body is rejected.
3. Queue actions locally when offline
For actions the user shouldn't lose (a form, a note, a check-in), save them locally first and sync later.
type Pending = { id: string; endpoint: string; body: unknown; createdAt: number };
const queue: Pending[] = loadFromStorage("queue") ?? []; // use persistent storage
export function enqueue(endpoint: string, body: unknown) {
queue.push({ id: crypto.randomUUID(), endpoint, body, createdAt: Date.now() });
saveToStorage("queue", queue);
void flush();
}
let flushing = false;
export async function flush() {
if (flushing) return;
flushing = true;
try {
while (queue.length) {
const item = queue[0];
try {
await withRetry(() =>
fetch(item.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json", "Idempotency-Key": item.id },
body: JSON.stringify(item.body),
}).then((r) => { if (!r.ok) throw Object.assign(new Error(), { status: r.status }); })
);
queue.shift();
saveToStorage("queue", queue);
} catch (e: any) {
if (e.status >= 400 && e.status < 500 && e.status !== 429) {
queue.shift(); // permanent failure: drop it (and tell the user)
saveToStorage("queue", queue);
} else {
break; // still offline or server down; try later
}
}
}
} finally {
flushing = false;
}
}
Call flush() on app start, when connectivity returns, and when the app comes to the foreground. Because each queued item has a stable ID used as its idempotency key, replays are safe.
UX details that matter
- Show a clear state: saved on device, syncing, synced or needs attention.
- Use timeouts. A request with no timeout can hang forever.
- Don't block the UI on writes the user can't meaningfully wait for. Update the UI optimistically, then reconcile.
- Test with network throttling and airplane mode, not only on office Wi-Fi.
Quick checklist
- [ ] Retries use backoff with jitter and only for retryable errors
- [ ] Every write endpoint that matters accepts an idempotency key
- [ ] Important user actions are saved locally before sending
- [ ] Sync runs on launch, reconnect and foreground
- [ ] The UI shows sync state
- [ ] Tested on a throttled or offline connection
How does your app handle a request that fails halfway? I'd like to hear what's worked.
Further reading: App Development
Top comments (0)