DEV Community

MACROGEN
MACROGEN

Posted on

Making Mobile Apps Survive Bad Networks: Retries, Idempotency and an Offline Queue

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++;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
    })
  );
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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)