The Server Action Retry Problem: Idempotency Keys for Next.js Mutations
Server actions look like function calls, but they're POST requests over an unreliable network. Here's how we stopped charging cards twice and duplicating orders in production.

Server actions feel like local function calls. They aren't. Every invocation is a POST request that can be retried by the browser, the user, a flaky mobile radio, or React itself — and we found that out the expensive way when a customer got charged three times for the same subscription.
This is the pattern we now use on every mutating server action: an idempotency key generated on the client, verified on the server, and stored just long enough to survive a retry window. It's boring, it works, and it should probably be a default.
Why server actions get called more than once
The React team is careful about naming things, and "action" is doing a lot of work here. It's the same word we use for local reducers, but the runtime behaviour is a network request with all the failure modes of a network request. In production we've seen the same server action fire twice or more from at least four sources:
- User double-clicks. The submit button is enabled for 40ms longer than it should be, and a determined user gets two POSTs in.
- Mobile network hiccups. The request goes out, the response never arrives, the user taps again.
- Browser navigation retries. If the tab is backgrounded during a slow action and the OS kills the process, some browsers will replay the pending POST when the tab is restored.
- Framework-level retries. React's transition machinery around
useActionStateanduseTransitionwill not retry a settled action, but middleware, service workers, and edge proxies absolutely will if you've configured them to.
None of these are bugs. They're the web. The bug is assuming your mutation handler will run exactly once.
The failure mode we shipped
Our checkout page called a createSubscription server action that hit Stripe, wrote a row to Postgres, and enqueued a welcome email. On a slow 4G connection with a distracted user, we produced three Stripe customers, three DB rows, and three welcome emails. The Stripe SDK call itself was safe because Stripe supports idempotency keys — we just weren't sending one. Everything downstream compounded the problem.
The pattern: client-generated key, server-side dedupe
The rule is simple: the client generates a unique key per logical intent, sends it with the action, and the server refuses to process the same key twice within a TTL.
"Per logical intent" is the important bit. The key is not per render, not per submit event, not per request — it's per thing the user is trying to do. If they click "Place order" and it fails, and they click it again, that's the same intent and should share a key. If they navigate away and come back to place a different order, that's a new intent.
In practice we generate the key when the form mounts, and rotate it after a successful action.
'use client';
import { useActionState, useRef, useEffect, useState } from 'react';
import { createSubscription } from './actions';
function newKey() {
return crypto.randomUUID();
}
export function CheckoutForm({ planId }: { planId: string }) {
const [idempotencyKey, setIdempotencyKey] = useState(newKey);
const [state, formAction, isPending] = useActionState(
createSubscription,
{ status: 'idle' as const }
);
useEffect(() => {
if (state.status === 'success') {
setIdempotencyKey(newKey());
}
}, [state.status]);
return (
<form action={formAction}>
<input type="hidden" name="planId" value={planId} />
<input type="hidden" name="idempotencyKey" value={idempotencyKey} />
<button type="submit" disabled={isPending}>
{isPending ? 'Processing…' : 'Place order'}
</button>
</form>
);
}
A few things worth calling out:
- The key lives in
useStatewith a lazy initializer, so it survives re-renders but not remounts. That's the behaviour we want. - We rotate the key on success, not on every render. Retries of a failed request must reuse the same key.
- We don't rotate on error, because most errors are the exact case where a retry might succeed — and where duplicate execution is most likely.
The server side
The server action needs to do three things in order: read the key, check whether it's been seen, and record the result atomically with the mutation.
'use server';
import { db } from '@/lib/db';
import { redis } from '@/lib/redis';
import { z } from 'zod';
const Input = z.object({
planId: z.string().min(1),
idempotencyKey: z.string().uuid(),
});
type ActionState =
| { status: 'idle' }
| { status: 'success'; subscriptionId: string }
| { status: 'error'; message: string };
export async function createSubscription(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const parsed = Input.safeParse({
planId: formData.get('planId'),
idempotencyKey: formData.get('idempotencyKey'),
});
if (!parsed.success) {
return { status: 'error', message: 'Invalid input' };
}
const { planId, idempotencyKey } = parsed.data;
const cacheKey = `idem:createSubscription:${idempotencyKey}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as ActionState;
}
// SET NX with TTL — reserves the key so a concurrent retry blocks.
const reserved = await redis.set(cacheKey, '__pending__', {
nx: true,
ex: 60 * 60 * 24,
});
if (!reserved) {
// Another request got here first. Wait briefly, then read the result.
await new Promise((r) => setTimeout(r, 500));
const result = await redis.get(cacheKey);
if (result && result !== '__pending__') {
return JSON.parse(result) as ActionState;
}
return { status: 'error', message: 'Please try again in a moment' };
}
try {
const subscription = await db.subscriptions.create({
planId,
idempotencyKey,
});
const result: ActionState = {
status: 'success',
subscriptionId: subscription.id,
};
await redis.set(cacheKey, JSON.stringify(result), { ex: 60 * 60 * 24 });
return result;
} catch (err) {
await redis.del(cacheKey);
return { status: 'error', message: 'Could not create subscription' };
}
}
Why Redis and not just the database
You can absolutely do this with a unique constraint on an idempotency_key column, and for a lot of use cases that's the right call — it's one fewer service and the guarantees are stronger. We reach for Redis when:
- The action touches multiple systems (Stripe, DB, queue) and we want the dedupe check to be cheap and fast before any of them.
- We want a short TTL. Idempotency keys shouldn't live forever; a 24-hour window is plenty and keeps the table small.
- We want to cache the response, not just the fact that we've seen the key, so retries return the same result the original caller would have seen.
If you go the database route, wrap the insert in a transaction and catch the unique-violation error. Do not do a SELECT then INSERT — you'll race yourself.
Delete on failure, or keep the key?
The example above deletes the cache entry on error, which lets the user retry with the same key and hit fresh execution. That's usually what you want for transient failures (network to Stripe timed out, DB was briefly unavailable).
It's the wrong call for deterministic failures — validation errors, insufficient funds, a plan that doesn't exist. Retrying will just fail again. In production we split errors into retryable and terminal and only delete the cache entry for the former. Terminal errors get cached with a shorter TTL (a few minutes) so a rage-clicking user doesn't hammer the backend.
What this doesn't solve
Idempotency keys stop duplicate execution. They don't stop the user from seeing stale UI, and they don't help you if the action has already committed but the response was lost in transit — from the client's perspective that looks identical to a failure.
Two things help:
- Cache the response, not just the key. The code above does this. A retry after a lost response returns the original success payload, and the UI recovers.
- Make the redirect the source of truth. After a successful mutation, redirect to a page whose URL encodes the resource ID (
/orders/[id]). If the user lands there, the order exists. If they don't, they can retry safely.
Where we'd start
If you've got server actions in production and haven't thought about this, do the audit today. Grep for 'use server', list every action that writes to a database or calls a third party, and rank them by blast radius. Payments, subscriptions, and anything that sends email or SMS go to the top.
Add idempotency keys to those first, using the pattern above. Don't try to build a generic middleware wrapper on day one — the ergonomics of useActionState and FormData make abstractions leaky, and hand-rolling the check per action takes maybe fifteen minutes. Once you've done three or four, the shape of a helper will be obvious, and you can factor it out with confidence.
If you'd rather have someone else stress-test your mutation layer before it costs you a customer, that's the kind of thing our web development team does regularly.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Cache Tags in Next.js 15: How We Stopped Nuking the Whole Site on Every Publish
revalidatePath('/') is the loaded gun in your CMS integration. Here's how we replaced it with a cache tag taxonomy that survives editorial workflows without evicting the world.

Parallel Routes and Intercepting Routes: The Modal Pattern That Survives a Hard Refresh
Next.js parallel and intercepting routes finally make modals feel native — deep-linkable, shareable, and refresh-safe. Here's the pattern that held up in production, and the places it quietly falls apart.

Streaming Suspense Boundaries: Why Your LCP Got Worse After the Refactor
We wrapped everything in Suspense expecting faster pages. Instead LCP got worse. Here's what streaming actually does to Core Web Vitals, and how to place boundaries so the numbers move the right way.
