The short answer
Remix does not document any client-side deduplication for useFetcher or Form submissions, and POST-Redirect-GET is not a retry safeguard. Each POST that reaches the server runs the action again, so the correct pattern is a server-side idempotency key enforced by a unique constraint in your data layer, with the redirect kept for UX and history semantics only.
What Remix actually guarantees
Confirmed behavior: a Remix action executes once per HTTP request it receives. If the browser, a proxy, or the user retries the submission — double-click, refresh before the redirect arrives, a transient network retry — the action body runs again and any non-idempotent write (insert, charge, email) is duplicated. Returning redirect() from the action implements POST-Redirect-GET, which solves exactly one problem: after success, the browser's last request is a safe GET, so refreshing the result page reissues a GET instead of prompting "confirm form resubmission." It does nothing for retries that happen before the redirect is received, and nothing for concurrent submissions.
Likely explanation for why this feels undocumented: the framework deliberately leaves mutation semantics to application code. Remix gives you the request and the redirect primitive; uniqueness of effects is a data concern, not a routing concern.
Client-side guards: useful, not sufficient
Disabling the submit button while useNavigation().state !== 'idle' is worth doing — it removes the most common double-click case. But it is a UX guard only. Fetch-level retries, back/forward resubmission, and a second tab all bypass it. Treat it as a complement, never as the mechanism.
The recommended server-side pattern
- Generate one idempotency key per logical operation. A common approach is a UUID rendered into a hidden form field when the form is generated, or a natural key derived from the operation (e.g. one order per cart checkout).
- Store that key with the write and back it with a database unique index, or use an upsert keyed on it.
- On a unique-constraint conflict, treat it as success: return the existing record and redirect to the same result page. Do not surface a 500 for what is actually a correct retry.
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
const idempotencyKey = String(form.get("idempotencyKey"));
try {
await db.order.create({ data: { idempotencyKey, /* ... */ } });
} catch (e) {
if (!isUniqueViolation(e)) throw e; // retry collapsed to one effect
}
return redirect(`/orders/confirmation?key=${idempotencyKey}`);
}
Action layer vs data layer
Enforce uniqueness in the data layer whenever the write must be exactly-once — payments, order creation, anything with side effects. A unique index is the only layer that survives concurrent requests, retries from other clients, and future code paths that skip your action. Action-level checks ("does a row with this key exist?") are fine as a fast path for friendlier error messages, but a check-then-insert without a constraint has a race window and is not a guarantee. For payments specifically, also pass the key to the provider's own idempotency support rather than relying on your database alone.
One decision to make deliberately: if the user resubmits the same rendered form (same hidden UUID), should that dedupe silently or be rejected? Dedupe is usually the right default for retries; rejection fits cases where a second submission signals a genuine user intent.
Verify it yourself
- Add a temporary counter or log line inside the action, then double-click submit and refresh mid-request — count how many times the body executes.
- Create the unique index, force a duplicate submission, and confirm you get one row plus a controlled conflict response, not a 500.
- Throttle the network in devtools, submit, and retry the request — the write should remain single.
- After success, refresh the result page and confirm the browser reissues a GET with no resubmission prompt.
Version note: this reflects long-standing Remix behavior for server-side actions and the web platform's POST semantics; it is not tied to a specific Remix release, but confirm the hidden-field and conflict-handling details against your current version and database driver.