Back to News & Insights
Web Development October 16, 2026 · 5 min read

Our client-side fetch retries POSTs, and a primary key is the only reason that is safe

A 90-line fetch wrapper used by 14 files, why it retries on 5xx but not 4xx, the one call site that deliberately opts out of retrying, and the error toast that parses the string the wrapper throws.

Our client-side fetch retries POSTs, and a primary key is the only reason that is safe

Almost every browser-initiated request in the CogniPrep dashboard goes through one 90-line module, lib/utils/fetch-helpers.ts. Fourteen files import it: profile edits, account deletion, session deletion, interview deletion, onboarding answers, the review prompt, the data export, the scoring hook.

Two places do not, and the line between them is worth stating before the code. The admin pages call fetch directly, in six client components, and I am the only person who can load them, so a request that deserves a retry gets one from me pressing the button again. One real inconsistency survives on the user side too: the activity log export button calls raw fetch, which is a straightforward oversight rather than a decision.

The module exports two functions, and the difference between them is the interesting part.

The AbortError translation is the whole reason this is not inlined. A raw abort gives you an error whose message is some variation of "The user aborted a request", which is both wrong (no user did anything) and useless in a bug report. Replacing it with the timeout and the URL means a Sentry issue says which call gave up and after how long.

response.status < 500 returns, it does not retry. A 400, a 401, a 403, a 429 and a 404 are all answers. The server considered the request and declined it, and sending the identical bytes again cannot change that answer. Retrying a 429 in particular is how a client turns a rate limit into a worse rate limit.

The backoff is linear, not exponential. retryDelay (attempt + 1) gives 1s then 2s, so worst case a user waits about three seconds plus two timeouts before seeing an error. Exponential backoff is for a background worker that can afford to wait; in front of somebody holding a phone, the ceiling matters more than the politeness.

The last attempt does not sleep. Obvious once stated, easy to get wrong, and a second of dead time at the end of a failed request is a second the spinner spins for nothing.

fetchJson is where the mutations live, and it retries all of them. Across the app that is four DELETE calls, three POSTs, one PUT and one PATCH.

Eight of those nine are naturally idempotent. Deleting the same session twice leaves one deleted session. Writing the same onboarding answers twice leaves one set of answers. Dismissing a prompt twice is a dismissed prompt.

The ninth is the one that saves a finished practice session, and it is only safe because of how the id is allocated:

The session id is generated in the browser and sent in the body, and in the schema it is the row's primary key:

So a retried save names the same row rather than asking for another one. Two identical POSTs cannot produce two rows, because the second one violates the primary key. The retry is safe for the reason an idempotency key makes a payment safe: the client, not the server, decides what this operation is called.

That has an honest second edge, and it is worth writing down rather than pretending it away. The save does the insert first and then updates the user's aggregate stats. If something fails between those two, the request is a 500 with the row already written, and the retry then fails on the duplicate key instead of succeeding. The user is told the save failed; the save did not fail. The primary key has converted a possible double-write into a guaranteed misleading error message, which is the right trade of the two, but it is a trade.

The data export button calls fetchWithTimeout directly, not fetchJson. It gets the long timeout because assembling an export of everything an account holds is not a 10 second job, and it gets no retries because a 30 second request retried twice is 90 seconds of a user staring at a button, for an operation they can simply press again. The choice of which helper to call is* the retry policy, which is why there are two exports and not one function with a flag.

This is the part I would flag in review if somebody else wrote it, and it is still what the code does:

fetchJson throws ` HTTP ${status}: ${body} . notifyError un-builds that string with a regex to show the server's own message without the status prefix in front of it. Two modules are coupled by a string format with no type between them, and renaming that template in the fetch helper would silently turn every error toast into the generic fallback.

What makes it tolerable is the guard at the end. The extracted message is only shown if it is between 1 and 200 characters, so the failure mode of the coupling is the generic message, never an HTML error page dumped into a toast. A 500 that returns a stack trace, a proxy error page, or an empty body all land on "Something went wrong. Please try again." The regex is best-effort by construction, and the length cap is what makes best-effort safe.

The honest fix is for the helper to throw a typed error carrying status and body` as fields. The reason it has not happened is that the current version is correct for every error the app actually produces, and I would rather write that down than quietly ship a refactor that pretends the string format was always a contract.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation