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,422moderation,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/attachduplicates media rows; a chat send withoutclientMessageIdduplicates the message).
1 — The short version
If you read nothing else:
| Retry | network errors, timeouts, 500, 502, 503, 504 |
| Don't retry | 400, 401, 403, 404, 409, 422 — the same request will fail the same way |
| Wait, then retry once | 429 — wait exactly retryAfter seconds (Rate Limits) |
| Refresh, then retry once | 401 — silent token refresh, then replay (Auth) |
| Attempts | 3 total (1 original + 2 retries) for reads |
| Backoff | 1s, 2s, 4s… capped at 10s, each multiplied by a random 0.5–1.5 |
| Per-attempt timeout | 20s. Not lower — a cold start legitimately takes ~8s |
| Never auto-retry | a 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
GETdoesn'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
500after ~6–8 seconds with no meaningful body. Handling is in the socket guide §2.1 — in short, don't forcetransports: ["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".
| Outcome | Retry? | 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
| Attempt | Nominal wait | With jitter | Elapsed (nominal) |
|---|---|---|---|
| 1 | — | — | 0s |
| 2 | 1s | 0.5–1.5s | 1s |
| 3 | 2s | 1–3s | 3s |
| 4 | 4s | 2–6s | 7s |
| 5 | 8s | 4–12s | 15s |
| 6+ | 10s | 5–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 call | Attempts | Reason |
|---|---|---|
Reads (GET) | 3 | Covers a cold start plus one bad moment |
| Idempotent writes (see §5) | 3 | Same — repeating is harmless |
| Non-idempotent writes | 1 | Surface the failure, let the user decide |
| Background/prefetch | 2 | Not 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
| Endpoint | Why it's safe |
|---|---|
Every GET | No side effects |
PUT /api/posts/:id/like | Carries the desired state in the body ({ liked: true }), not a toggle. Applying it twice lands in the same place |
PUT /api/posts/:id/save | Same shape, same reason |
POST /api/chat/conversations/:id/messages with clientMessageId | The server dedupes on it and answers deduplicated: true on the replay |
POST /api/chat/conversations for a DM | Returns the existing DM rather than making a second one |
| Report endpoints | Idempotent per (reporter, target) |
This is why toggles are
PUTwith a desired state instead ofPOST /toggle. A retried toggle flips twice and lands wrong; a retriedPUTdoesn'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
| Endpoint | What a blind retry does |
|---|---|
POST /api/media/attach | Inserts 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 clientMessageId | Second message in the thread. Always send the id — it is the only reason the retry above is safe |
POST /api/posts and other creates | Second post |
POST /api/auth/refresh | Rotates 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:
- Presign — safe to retry. A wasted presign is a wasted URL, nothing more.
PUTto storage — safe to retry. Same key, same object, overwritten.- 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
| Setting | Value | Why |
|---|---|---|
| Connect + response timeout | 20s | A cold start legitimately consumes ~8s of it |
Socket.IO timeout | 20s | Same reason — socket guide §2 |
Media PUT to storage | 60s+ | Large files on mobile uplinks; storage is not behind the cold start |
| Total budget per user action | 30s | Across 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't | Why |
|---|---|
| Retry on a fixed 1s interval | Ten attempts inside one 8s cold start, all failing for the same reason |
| Retry without jitter | One instance, every client released at once — see §4 |
Retry a 4xx | The 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 create | Duplicate posts, duplicate media, duplicate messages (§5) |
Generate a fresh clientMessageId per attempt | Defeats server-side dedupe; the retry becomes a second message |
Invent your own delay for a 429 | The server sent retryAfter. Ignoring it extends the lockout |
| Show an error at 3s | That's a cold start, not a failure (§7) |
Poll /health on a timer | Once per foreground transition is the point; a timer just keeps an instance alive on the user's battery |
Block the chat screen on socket.connected | Chat works over HTTP without the socket |