Skip to main content
Version: Latest

Request Reliability — Integration Guide

One retry policy for the whole app. Which failures are worth retrying, which are not, how long to wait between attempts, when a repeat is safe and when it silently creates a duplicate, and why the first request after a quiet period can take eight seconds. Build this once in your HTTP client and your socket setup; do not re-decide it per screen.

  • API base URL (local): http://localhost:3001
  • Auth: Authorization: Bearer <accessToken> — see Auth Integration
  • This guide is about transport failures. Business errors (429, 422 moderation, 403) have their own guides and are linked where they come up.

What's new​

  • 2026-09-16 — New guide. The server runs on one instance that shuts down when idle, so the first request after a few quiet minutes waits ~8s for a cold start. Plain HTTP requests survive that (they're queued) — websocket upgrades do not, which is why transports: ["websocket"] has been removed from the socket guide. Everything else here is the retry policy to build once: what's retryable, the backoff numbers, and the list of endpoints that are not safe to repeat (POST /api/media/attach duplicates media rows; a chat send without clientMessageId duplicates the message).

1 — The short version​

If you read nothing else:

Retrynetwork errors, timeouts, 500, 502, 503, 504
Don't retry400, 401, 403, 404, 409, 422 — the same request will fail the same way
Wait, then retry once429 — wait exactly retryAfter seconds (Rate Limits)
Refresh, then retry once401 — silent token refresh, then replay (Auth)
Attempts3 total (1 original + 2 retries) for reads
Backoff1s, 2s, 4s… capped at 10s, each multiplied by a random 0.5–1.5
Per-attempt timeout20s. Not lower — a cold start legitimately takes ~8s
Never auto-retrya POST that creates something, unless it takes an idempotency key

2 — Why the first request is slow (cold start)​

The API runs on Cloud Run capped at a single instance, and that instance is shut down after a few idle minutes. This is deliberate for now — the server keeps live socket state in process memory, so a second instance would not see the first one's connections. One instance is the correct shape for this stage of the product, and the cost is a cold start.

Waking it takes about 8 seconds: container start, then Node boot, then the server begins listening.

What that means for you:

  • Plain HTTP requests survive it. The platform queues the request until the server is listening, then delivers it. Your GET doesn't fail — it takes 8 seconds. This is the entire reason the per-attempt timeout below is 20s and not 5s.
  • WebSocket upgrades do not survive it. They are not queued. The upgrade comes back 500 after ~6–8 seconds with no meaningful body. Handling is in the socket guide §2.1 — in short, don't force transports: ["websocket"], and let the reconnect backoff run.

Warm the server on foreground​

Cheap and worth doing. When the app comes to the foreground, fire and forget:

GET /health

No auth, no body worth reading ({"status":"ok","timestamp":"…"}). It is an ordinary request, so it gets queued through the cold start and wakes the instance while the user is still looking at your splash or home skeleton. By the time they tap something, the server is up and the socket connects on the first try.

Do not poll it on a timer. Once per foreground transition is the whole idea.


3 — Which failures are retryable​

Retrying a request that failed for a deterministic reason just burns battery and delays the error the user needs to see. Branch on the cause, not on "did it fail".

OutcomeRetry?Notes
Network unreachable, DNS failure, connection reset✅The classic flaky-mobile case
Request timed out (no response)⚠️Safe for reads. For writes see §5 — you don't know whether the server acted
500 Internal Server Error✅Usually transient here (a restart mid-request)
502 / 503 / 504✅Platform-level; the instance was starting, stopping, or replaced
429 Too Many Requests⏱Wait retryAfter seconds, then one retry. Do not back off your own way — the server tells you the number. Rate Limits
401 Unauthorized🔑Not a transport failure. Refresh the access token, replay the request once. If the refresh itself 401s, go to login. Auth
400 Bad Request❌Your payload is wrong. Retrying sends the same wrong payload
403 Forbidden❌Permission. Won't change on retry
404 Not Found❌Also what you get for content you're not allowed to see — posts return 404, never 403, so ids can't be probed. Either way, don't retry
409 Conflict❌State already moved. Refetch instead
422 Unprocessable❌Validation or the blocked-word filter. Moderation

Rule of thumb: 4xx is you, 5xx is us. Retry us, not you — with 429 and 401 as the two exceptions that have their own instruction attached.


4 — Backoff: the numbers​

Exponential backoff with jitter. Three parameters, and the jitter is not optional.

delay(attempt) = min(BASE * 2^(attempt-1), MAX) * random(0.5, 1.5)

BASE = 1000ms
MAX = 10000ms
AttemptNominal waitWith jitterElapsed (nominal)
1——0s
21s0.5–1.5s1s
32s1–3s3s
44s2–6s7s
58s4–12s15s
6+10s5–15s+10s each

Why MAX is 10s and not 5s: a cold start is ~8s. A 5s ceiling means several attempts land inside one boot and all fail for the same reason.

Why jitter matters here specifically: with one server instance, every client that was waiting gets released at the same moment and every client that failed retries at the same moment. The two 500s that prompted this guide arrived 14ms apart from different users. Without jitter your whole userbase retries in lockstep and hammers an instance that is still booting. random(0.5, 1.5) costs one line and spreads them out.

How many attempts:

Kind of callAttemptsReason
Reads (GET)3Covers a cold start plus one bad moment
Idempotent writes (see §5)3Same — repeating is harmless
Non-idempotent writes1Surface the failure, let the user decide
Background/prefetch2Not worth battery; it'll run again

Cap total elapsed time too, not just attempt count: 30 seconds across all attempts. If a screen can't load in 30s, the user needs an error and a Retry button, not a fourth silent attempt.


5 — Idempotency: when a repeat is safe​

A retry after a timeout is the dangerous case. You never received a response, so you cannot know whether the server processed the request. Retrying a create in that state is how users end up with two of something.

Safe to repeat — retry freely​

EndpointWhy it's safe
Every GETNo side effects
PUT /api/posts/:id/likeCarries the desired state in the body ({ liked: true }), not a toggle. Applying it twice lands in the same place
PUT /api/posts/:id/saveSame shape, same reason
POST /api/chat/conversations/:id/messages with clientMessageIdThe server dedupes on it and answers deduplicated: true on the replay
POST /api/chat/conversations for a DMReturns the existing DM rather than making a second one
Report endpointsIdempotent per (reporter, target)

This is why toggles are PUT with a desired state instead of POST /toggle. A retried toggle flips twice and lands wrong; a retried PUT doesn't. When you add an optimistic UI, send the state you want, never "the opposite of what I think is there".

NOT safe to repeat — one attempt, then ask the user​

EndpointWhat a blind retry does
POST /api/media/attachInserts a second Media row pointing at the same uploaded object. The upload isn't duplicated; the attachment is. Two copies on the post
POST /api/chat/.../messages without clientMessageIdSecond message in the thread. Always send the id — it is the only reason the retry above is safe
POST /api/posts and other createsSecond post
POST /api/auth/refreshRotates the refresh token again. See the concurrent-rotation note in Auth — if the response has no refreshToken, another request already rotated it; keep the one you have

Generate clientMessageId on the client, before the first attempt, and reuse the same value for every retry of that send. A fresh UUID per attempt defeats the whole mechanism — that is the one mistake this section exists to prevent.

For creates with no idempotency key, the recovery is reconcile, don't retry: on timeout, refetch the list and look for what you were creating. If it's there, the write landed. If it isn't, offer Retry.

The media upload flow specifically​

Presign → PUT to the signed URL → attach. Each step retries differently:

  1. Presign — safe to retry. A wasted presign is a wasted URL, nothing more.
  2. PUT to storage — safe to retry. Same key, same object, overwritten.
  3. Attach — not safe (see above). On timeout, refetch the owner (post, message) and check whether the media is on it before trying again.

6 — Per-attempt timeout​

SettingValueWhy
Connect + response timeout20sA cold start legitimately consumes ~8s of it
Socket.IO timeout20sSame reason — socket guide §2
Media PUT to storage60s+Large files on mobile uplinks; storage is not behind the cold start
Total budget per user action30sAcross all attempts, then show an error

Do not lower the 20s to "fail fast". The failures that motivated this guide came back at 6.2s and 8.3s — already inside a 20s deadline. A 10s timeout would abort requests that were about to succeed and turn a slow first load into a broken one.


7 — What the user sees​

Retries are invisible infrastructure right up until they aren't.

  • 0–2s — normal loading state.
  • 2–15s — still "Loading…". This is cold-start territory. Do not show an error, a failure icon, or a Retry button here; the request is very likely still in flight and about to succeed.
  • After ~15s — you may show "Taking longer than usual".
  • After the budget (30s) or a non-retryable error — a real error with a Retry button. Manual retry resets the attempt counter.

Never show a toast per failed attempt. Three attempts must produce at most one message.

While the socket is down the app is not down — chat, history, and sending all work over HTTP. Don't gate screens on socket.connected.


8 — Drop-in fetch wrapper​

One place, wrapping every call. Adapt to your HTTP client; the logic is the point, not the library.

const BASE_MS = 1000;
const MAX_MS = 10_000;
const ATTEMPT_TIMEOUT_MS = 20_000;
const TOTAL_BUDGET_MS = 30_000;

const RETRYABLE_STATUS = new Set([500, 502, 503, 504]);

function backoff(attempt: number): number {
const nominal = Math.min(BASE_MS * 2 ** (attempt - 1), MAX_MS);
return nominal * (0.5 + Math.random()); // jitter — see §4
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export async function apiFetch(
path: string,
init: RequestInit = {},
// Callers that create something pass { attempts: 1 } — see §5.
opts: { attempts?: number } = {},
): Promise<Response> {
const maxAttempts = opts.attempts ?? 3;
const deadline = Date.now() + TOTAL_BUDGET_MS;
let refreshed = false;

for (let attempt = 1; ; attempt++) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), ATTEMPT_TIMEOUT_MS);

try {
const res = await fetch(`${API_URL}${path}`, {
...init,
signal: ac.signal,
headers: {
"Content-Type": "application/json",
"X-Client-Type": "mobile",
"X-Request-Id": crypto.randomUUID(), // fresh per attempt, for tracing
Authorization: `Bearer ${await getAccessToken()}`,
...init.headers,
},
});

// 401 — refresh once, then replay. Not counted as a retry attempt.
if (res.status === 401 && !refreshed) {
refreshed = true;
await refreshAccessToken(); // throws → caller bounces to login
continue;
}

// 429 — the server tells you how long. Obey it, don't invent a delay.
if (res.status === 429) {
const body = await res.clone().json().catch(() => ({}));
const waitMs = (body.retryAfter ?? 1) * 1000;
if (attempt >= maxAttempts || Date.now() + waitMs > deadline) return res;
await sleep(waitMs);
continue;
}

if (!RETRYABLE_STATUS.has(res.status)) return res; // success or a 4xx you own
if (attempt >= maxAttempts) return res;

} catch (err) {
// Network failure or our own abort. Both are retryable for reads; callers
// that create something passed attempts: 1 and fall straight through.
if (attempt >= maxAttempts) throw err;
} finally {
clearTimeout(timer);
}

const wait = backoff(attempt);
if (Date.now() + wait > deadline) break;
await sleep(wait);
}

throw new Error(`Request failed after ${maxAttempts} attempts: ${path}`);
}

Usage:

// Read — default 3 attempts
const res = await apiFetch(`/api/posts/${id}`);

// Create — one attempt; on failure, reconcile or let the user retry (§5)
const res = await apiFetch("/api/posts", { method: "POST", body }, { attempts: 1 });

// Chat send — safe to retry BECAUSE of clientMessageId, generated once
const clientMessageId = crypto.randomUUID();
const res = await apiFetch(
`/api/chat/conversations/${cid}/messages`,
{ method: "POST", body: JSON.stringify({ content, clientMessageId }) },
);

9 — Anti-patterns​

Each of these has a real failure attached to it.

Don'tWhy
Retry on a fixed 1s intervalTen attempts inside one 8s cold start, all failing for the same reason
Retry without jitterOne instance, every client released at once — see §4
Retry a 4xxThe same request fails the same way. You've doubled the delay before the user learns something is wrong
Lower the timeout to "fail fast"Aborts requests that were about to succeed (§6)
Auto-retry a createDuplicate posts, duplicate media, duplicate messages (§5)
Generate a fresh clientMessageId per attemptDefeats server-side dedupe; the retry becomes a second message
Invent your own delay for a 429The server sent retryAfter. Ignoring it extends the lockout
Show an error at 3sThat's a cold start, not a failure (§7)
Poll /health on a timerOnce per foreground transition is the point; a timer just keeps an instance alive on the user's battery
Block the chat screen on socket.connectedChat works over HTTP without the socket
⤓ Download .md