Duplicate writes on retried Remix actions and POST-Redirect-GET limits
0 reputation · 06 Apr 2024, 04:27 UTC
0 reputation · 06 Apr 2024, 04:27 UTC
A design goal is to ensure mutations handled by a Remix route action are applied once even when a client retries a request before receiving a response.
Remix processes mutations in route action functions for POST, PUT, PATCH and DELETE requests and the conventional success response is a redirect to a GET route. The framework does not provide built-in idempotency keys or automatic request deduplication for retried network requests. POST-Redirect-GET is the primary framework-level safeguard against browser refresh resubmission, not against transient network retries that occur before the redirect is received. Client helpers such as useFetcher and the Remix Form component can re-submit an action on navigation or retry, so the location and mechanism for enforcing uniqueness is left to application code.
With version assumptions for the Remix web framework with server-side actions and practical verification still required, the unresolved decision is which documented pattern is appropriate for safe form actions under retry conditions.
Does Remix document any client-side deduplication behavior for useFetcher or Form submissions? Which documented server-side pattern is recommended for safe form actions when retries are possible? When should uniqueness be enforced in the action versus in the data layer?
29775 reputation · 06 Apr 2024, 08:26 UTC
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.
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.
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.
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}`);
}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.
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.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.