Idempotent UI Actions
How to design UI actions that produce the same result regardless of how many times they fire — client-side guards, server-side idempotency keys, React Strict Mode as an idempotency test, and why both layers are required.

Overview
An idempotent action produces the same outcome when triggered multiple times as when triggered once. In UI development this matters because users double-click buttons, networks retry requests, React Strict Mode invokes effects twice in development, and optimistic updates can race with server responses.
If your "Place Order" button submits twice on a slow connection, idempotency is the difference between one charge and two.
Idempotency must be enforced at two levels. Client-side guards (disabled states, in-flight checks) are UX — they reduce duplicate triggers under normal conditions. Server-side idempotency keys are correctness — they handle retries, network replays, and the cases client guards miss.
How It Works
Why Both Layers Are Required
Client-side guards fail in several scenarios: the user disables JavaScript before a request completes, the network retries the request automatically (some proxies and service workers do this), a React Server Action is re-invoked due to a network timeout, or a mobile device resumes a suspended tab and replays a pending action. In all these cases, the request reaches the server a second time — the client guard isn't consulted.
Server-side idempotency key: A unique token, generated per user intent and sent with every request. The server checks whether it has already processed this token. If it has, it returns the same success response without repeating the side effect. If it hasn't, it processes normally and stores the token with the result.
Generating the Key
The key must be:
- Stable across retries of the same intent — generated once per user intent (e.g., one
useRefper checkout session), reused for all retries - Unique across different intents — a new key when the user starts a fresh action after a confirmed success
- Unpredictable —
crypto.randomUUID()provides cryptographic randomness
Code Examples
Pattern 1: Client-Side Guard — Disabled State
// app/checkout/PlaceOrderButton.tsx
"use client";
import { useState } from "react";
export function PlaceOrderButton({ cartId }: { cartId: string }) {
const [status, setStatus] = useState<"idle" | "pending" | "done" | "error">(
"idle",
);
async function handleClick() {
if (status !== "idle") return; // absorb duplicate triggers
setStatus("pending");
try {
const res = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cartId }),
});
if (!res.ok) throw new Error("Order failed");
setStatus("done");
} catch {
setStatus("error");
}
}
return (
<button
onClick={handleClick}
disabled={status === "pending"}
aria-busy={status === "pending"}
aria-label={
status === "pending"
? "Placing order, please wait"
: status === "done"
? "Order placed"
: "Place order"
}
>
{status === "idle" && "Place Order"}
{status === "pending" && "Placing order…"}
{status === "done" && "Order placed ✓"}
{status === "error" && "Try again"}
</button>
);
}Pattern 2: Server-Side Idempotency Key
The key is generated on the client and checked on the server. Retries reuse the same key — the server deduplicates:
// app/checkout/PlaceOrderButton.tsx
"use client";
import { useRef, useState } from "react";
import { placeOrder } from "./actions";
export function PlaceOrderButton({ cartId }: { cartId: string }) {
// useRef: key is generated once per mount, stable across retries
// Changing it would make each retry look like a new order to the server
const idempotencyKey = useRef(crypto.randomUUID());
const [status, setStatus] = useState<"idle" | "pending" | "done" | "error">(
"idle",
);
async function handleClick() {
if (status !== "idle") return;
setStatus("pending");
try {
await placeOrder(idempotencyKey.current, cartId);
setStatus("done");
} catch {
// On error, allow retry — same key ensures no duplicate order
setStatus("error");
}
}
function handleRetry() {
// IMPORTANT: reuse the same key for retry — server will deduplicate
// Only generate a new key when starting a genuinely new order
setStatus("idle");
}
return (
<div>
<button onClick={handleClick} disabled={status === "pending"}>
{status === "pending" ? "Placing order…" : "Place Order"}
</button>
{status === "error" && (
<button onClick={handleRetry}>Something went wrong — try again</button>
)}
</div>
);
}// app/checkout/actions.ts
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function placeOrder(idempotencyKey: string, cartId: string) {
// Check for existing processed request with this key
const existing = await db.order.findUnique({
where: { idempotencyKey },
});
// Already processed — return the same result, no side effect repeated
if (existing) return existing;
// First time seeing this key — create the order atomically
const order = await db.order.create({
data: {
idempotencyKey, // store the key with the order
cartId,
status: "confirmed",
},
});
revalidatePath("/orders");
return order;
}Pattern 3: useTransition — Native React Concurrency Guard
useTransition exposes a isPending flag that React manages during the transition. It prevents re-entrance into the action while one is in-flight:
"use client";
import { useTransition, useRef } from "react";
import { placeOrder } from "./actions";
export function OrderButton({ cartId }: { cartId: string }) {
const [isPending, startTransition] = useTransition();
const idempotencyKey = useRef(crypto.randomUUID());
function handleClick() {
// startTransition is non-reentrant for the same transition
startTransition(async () => {
await placeOrder(idempotencyKey.current, cartId);
});
}
return (
<button onClick={handleClick} disabled={isPending} aria-busy={isPending}>
{isPending ? "Processing…" : "Place Order"}
</button>
);
}React Strict Mode as an Idempotency Test
React Strict Mode in development deliberately invokes useEffect cleanup and re-run twice, and calls useState initializers twice. This intentionally exposes non-idempotent side effects:
"use client";
import { useEffect } from "react";
// ❌ Non-idempotent — Strict Mode exposes this bug in development
export function OrderConfirmation({ orderId }: { orderId: string }) {
useEffect(() => {
// Strict Mode runs this twice in development
// This would create two analytics events, or two server calls
fetch("/api/analytics", {
method: "POST",
body: JSON.stringify({ event: "order_confirmed", orderId }),
});
}, [orderId]);
return <p>Order confirmed!</p>;
}
// ✅ Idempotent — safe under Strict Mode and real retries
export function OrderConfirmationSafe({ orderId }: { orderId: string }) {
useEffect(() => {
let active = true;
async function trackConfirmation() {
if (!active) return;
await fetch("/api/analytics/track", {
method: "POST",
body: JSON.stringify({
event: "order_confirmed",
orderId,
idempotencyKey: `order_confirmed_${orderId}`, // deterministic key per event
}),
});
}
trackConfirmation();
return () => {
active = false;
};
}, [orderId]);
return <p>Order confirmed!</p>;
}Treat Strict Mode double-invocation as a feature. If your useEffect breaks
when run twice, it exposes a real bug. Fix the effect to be idempotent rather
than suppressing Strict Mode.
Idempotency Key Expiry — Choosing the Right TTL
The idempotency key must remain in the database long enough to cover all reasonable retry windows:
// app/api/orders/route.ts
export async function POST(request: Request) {
const { cartId, idempotencyKey } = await request.json();
if (!idempotencyKey) {
return Response.json(
{ error: "idempotencyKey is required" },
{ status: 400 },
);
}
// Check if key was processed within its TTL
const existing = await db.idempotencyKey.findUnique({
where: { key: idempotencyKey },
});
if (existing) {
if (existing.expiresAt < new Date()) {
// Expired — allow reprocessing (new intent)
await db.idempotencyKey.delete({ where: { key: idempotencyKey } });
} else {
// Still valid — return the stored result
return Response.json(existing.result);
}
}
// Process the order
const order = await db.order.create({
data: { cartId, status: "confirmed" },
});
// Store the result with a 24-hour TTL
// TTL must exceed maximum expected processing time (payment gateway: up to 30s)
// plus a safe margin for slow clients (24h handles any realistic retry window)
await db.idempotencyKey.create({
data: {
key: idempotencyKey,
result: order,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
},
});
return Response.json(order, { status: 201 });
}Real-World Use Case
E-commerce checkout on a flaky mobile connection. A user taps "Place Order." The request is sent but the response times out — the app doesn't know if the server processed it. The app automatically retries with the same idempotency key. The server processes the retry, finds the key already in the database, and returns the original order — no second charge, no duplicate fulfillment email. The user sees "Order confirmed" on the retry. Without server-side idempotency, they'd receive two orders and two charges — a critical failure for a payment flow.
The same pattern applies to: email dispatch (prevent duplicate confirmation emails), invoice creation (prevent duplicate billing), resource provisioning (prevent double-provisioning infrastructure), and webhook handlers (prevent duplicate processing when a webhook is retried by the sender).
Common Mistakes / Gotchas
1. Generating a new idempotency key on every retry. If crypto.randomUUID() is called inside the click handler, each retry creates a new UUID — each looks like a completely new order to the server. The key must be stable for the duration of a single user intent. Use useRef initialized once per component mount.
2. Only enforcing idempotency on the client. Client guards (disabled state, isPending) prevent double-clicks under normal conditions but fail for: network retries, service worker request replays, React Server Action re-invocations on timeout, and tab suspends/resumes on mobile. Server-side key checking is the only reliable defense.
3. Expiring idempotency keys too aggressively. If the key expires in 60 seconds but your payment gateway can take up to 30 seconds to respond and the client retries after 90 seconds, the expired key allows a second charge. Key TTL must comfortably exceed: max processing time + max retry interval.
4. Not returning the same response on duplicate keys. Some implementations detect the duplicate but return 409 Conflict instead of the original success response. Client code retrying on non-2xx responses will retry again — defeating the purpose. Always return the original success response for a recognized idempotency key.
5. Using Strict Mode suppression instead of fixing effects. When Strict Mode's double-invocation exposes a bug, the correct fix is to make the effect idempotent — not to add if (process.env.NODE_ENV !== 'development') guards or disable Strict Mode. The double-invocation is exposing a real production bug.
Summary
Idempotent UI actions guarantee that performing the same action multiple times has no additional effect beyond the first execution. Client-side guards (disabled state, useTransition, useRef key) handle UX — reducing accidental double-triggers in the happy path. Server-side idempotency keys stored in the database handle correctness — covering network retries, service worker replays, and all cases client guards miss. Generate the key once per user intent (via useRef) and reuse it across retries. React Strict Mode's deliberate double-invocation of effects is an idempotency test — treat it as a feature, not a nuisance. Key TTL must exceed the maximum expected processing time.
Interview Questions
Q1. What is an idempotency key and why is it stored on the server?
An idempotency key is a unique token generated per user intent on the client, sent with every mutation request. On the server, it's stored alongside the result after the first processing. On subsequent requests with the same key, the server finds the stored result and returns it immediately without re-executing the side effect. The key is stored on the server because only the server can authoritatively determine whether it has seen a request before — client-side deduplication is best-effort and bypassed by any path that doesn't go through the React component tree (service worker retries, proxy retries, direct API calls).
Q2. Why should you use useRef for the idempotency key instead of useState or useMemo?
useRef provides a stable container that persists across re-renders without triggering re-renders when its value changes. The idempotency key must not change across re-renders of the same component instance — it must be the same for the initial request and all retries within the same user intent. useState would work too, but it triggers a re-render when set (unnecessary here). useMemo is wrong because it might recompute the UUID on re-renders (if its deps change) or it might return a cached value — crypto.randomUUID() should be called exactly once per component mount. useRef(() => crypto.randomUUID()) is clean and semantically correct: initialize once, persist forever.
Q3. How does React Strict Mode function as an idempotency test?
In React 18+ development mode, Strict Mode deliberately mounts components twice (mount → unmount → remount) and runs useEffect twice (effect → cleanup → effect). This exposes any side effects in useEffect that are not idempotent — analytics events that fire twice, network requests that create duplicate records, subscriptions that register multiple times. The double-invocation is intentional: it simulates what would happen if React unmounts and remounts a component due to off-screen rendering, Hot Module Replacement, or future concurrent features. If your effect breaks when run twice, it will break in production too. The fix is always to make the effect idempotent, not to suppress Strict Mode.
Q4. What is the correct TTL for an idempotency key and what happens if it expires?
The TTL should exceed the maximum expected processing time for the operation plus the maximum retry window. For payment processing: if the payment gateway can take up to 30 seconds and the client retries up to 3 times with exponential backoff over 10 minutes, the minimum TTL is 10 minutes + margin. In practice, 24 hours is a safe default for most payment flows. When a key expires: treat it as a new intent — delete the stored key and allow reprocessing. This handles the case where a user genuinely starts a new checkout session the next day with a stale key. The risk of too-short TTL is duplicate charges; the risk of too-long TTL is wasted storage, which is the lesser concern.
Q5. Why is returning 409 Conflict for a duplicate idempotency key wrong?
When a client receives a non-2xx response, most retry logic treats it as a failure and retries. If the server returns 409 Conflict for a recognized duplicate key, the client retries — receives another 409 — retries again — creating an infinite retry loop, a thundering herd, or eventually exhausting retry budget and showing the user an error. The correct behavior: return the exact same success response (same status code, same body) that was returned for the original successful request. The client doesn't know whether this was the first execution or a duplicate — and it shouldn't need to. The idempotency guarantee is: "calling me twice is the same as calling me once."
Q6. When is a client-side guard sufficient and when is a server-side idempotency key required?
A client-side guard (disabled state, isPending flag) is sufficient when: the action has no permanent side effects (a read-only query), the duplicate trigger is purely a UI/UX concern (a toggle that's annoying to double-trigger but not harmful), or the action is explicitly designed to be additive and re-triggering is expected (pagination load-more). A server-side idempotency key is required when: the action creates a permanent record (orders, invoices, emails), the request might be retried automatically by the network layer or service worker, the action involves payment or billing, or the consequence of a duplicate is user-visible harm (double charge, duplicate email). For any action with real-world consequences, use both layers — client guards for UX, server key for correctness.
Optimistic UI & Rollback
Showing immediate feedback before server confirmation — useOptimistic for Server Actions, TanStack Query onMutate/onError/onSettled for client mutations, concurrent mutation handling, and rollback for drag-and-drop interactions.
Offline Conflict Resolution
Detecting and resolving data conflicts when offline writes sync back to the server — version numbers, vector clocks, CRDTs (Automerge/Yjs), Background Sync API, and IndexedDB write queues.