Your users do not care that their Wi-Fi dropped between your API receiving a request and your server returning a response.

They care that clicking "Pay" once did not charge them twice.

They care that refreshing the page did not create two orders.

They care that a mobile app retrying after a tunnel ride did not send duplicate invite emails to the whole company.

That is why idempotency is not only distributed-systems hygiene. It is product behavior.

Most teams meet idempotency during payment integration.

Stripe asks for Idempotency-Key.

AWS asks for ClientToken.

You add a UUID header, cache the response, and move on.

But that framing is too small.

Idempotency defines whether your product remains predictable when the network stops being perfect.

And networks are never perfect.

  • Requests time out.

  • Mobile apps get killed mid-flight.

  • Browsers retry.

  • Load balancers retry.

  • SDKs retry.

  • Users double-click.

  • Queue consumers receive the same message again.

  • Webhooks arrive multiple times.

  • Deployments interrupt requests after the database commit but before the response.

None of these are exotic failures.

They are normal operating conditions for systems that communicate over networks.

If your product cannot answer:

"What happens when the same intent arrives twice?"

then your product has a gap.

The failure users see is not:

"Non-idempotent POST request."

They see:

  • Duplicate charge

  • Duplicate booking

  • Two cloud machines running

  • Ten identical Slack alerts

  • Account created but onboarding failed

  • "Something went wrong" despite the action succeeding

  • Support unable to determine whether an order exists

That last one matters more than engineers often admit.

Ambiguity is product debt.

The most expensive state in distributed systems is not failure.

It is:

"We do not know."

The Request That Disappeared

Consider a checkout flow:

  1. User clicks Pay.

  2. Client sends POST /payments.

  3. Server creates the payment successfully.

  4. Response gets lost due to a network timeout.

  5. Client does not know whether the payment happened.

  6. User clicks Pay again.

What should happen?

Without idempotency, the second request may create a second payment.

// Without idempotency double charge const res = await fetch('/payments', { method: 'POST', body: JSON.stringify({ amount: 4999, currency: 'usd' }), }); if (!res.ok) { // Timeout or network error user retries const retry = await fetch('/payments', { method: 'POST', body: JSON.stringify({ amount: 4999, currency: 'usd' }), }); // Two charges. Refund workflow. Support ticket. Lost trust. } 

You now have:

  • Duplicate charge

  • Refund workflow

  • Support ticket

  • Lost trust

  • Finance reconciliation work

  • User wondering whether your product is safe

With idempotency:

// With idempotency one charge const key = crypto.randomUUID(); // Generated once, on user action const sendPayment = async (retryKey: string) => { const res = await fetch('/payments', { method: 'POST', headers: { 'Idempotency-Key': retryKey }, body: JSON.stringify({ amount: 4999, currency: 'usd' }), }); return res.json(); }; // First attempt await sendPayment(key); // On timeout, retry with SAME key await sendPayment(key); // Server recognizes key, returns original result. // One payment. One receipt. One coherent story. 

Notice what changed.

Not just database correctness.

The user experience changed from:

"Did you charge me twice?"

to

"The network was flaky, but my payment went through."

That is product quality.

Retries Are Product Behavior

We often treat retries as infrastructure plumbing.

  • Exponential backoff

  • Jitter

  • Timeout tuning

  • Circuit breakers

All useful.

None answer the real question.

Can the caller retry safely?

A retry policy without idempotency is a machine gun attached to your foot.

If an operation:

  • creates a resource

  • sends money

  • reserves inventory

  • issues a token

  • dispatches notifications

  • triggers third-party side effects

then retries can multiply outcomes.

// Retry without idempotency dangerous async function deployCluster(config: ClusterConfig) { try { return await api.post('/clusters', config); } catch (err) { if (isTimeout(err)) { // Retry but the first call may have succeeded return await api.post('/clusters', config); // Two clusters now running } throw err; } } 

A reliable system needs two promises:

  1. Temporary failure should be recoverable.

  2. Retrying must not create additional user-visible side effects.

Idempotency is the contract that makes both possible.

// Retry with idempotency safe async function deployCluster(config: ClusterConfig) { const key = getDeploymentKey(config); // Derived from user action, not request try { return await api.post('/clusters', config, { headers: { 'Idempotency-Key': key }, }); } catch (err) { if (isTimeout(err)) { // Retry with same key server returns existing cluster or processes return await api.post('/clusters', config, { headers: { 'Idempotency-Key': key }, }); } throw err; } } 

Same Payload Does Not Mean Same Intent

A common implementation hashes the request body:

hash(userId + requestBody) 

It feels clever.

It is often wrong.

Imagine a user launches two identical EC2 instances.

Same:

  • Image

  • Region

  • Instance type

  • Payload

Are they duplicates?

Maybe.

Maybe not.

The payload cannot reliably tell you intent.

// Payload-based dedup flawed function generateKey(req: Request) { const body = req.body; return `${body.userId}-${body.image}-${body.region}-${body.instanceType}`; } // User launches two identical instances intentionally // Both get same key second one rejected // User confused: "Why won't my second instance start?" 

Instead, let the client generate an explicit idempotency key.

The client is saying:

These retries belong to one logical action.

// Client-controlled key correct function onDeployClick() { const key = crypto.randomUUID(); // New key per user action api.post('/instances', payload, { headers: { 'Idempotency-Key': key }, }); } // Same key + same caller = same operation (retry) // New key + identical payload = possibly new operation (intentional) 

That subtle distinction protects product semantics.

Good Idempotency Isn't "Ignore Duplicates"

Stripe compares request parameters when the same key is reused.

Suppose:

checkout-123 

first means:

Buy Pro Plan 

Later the same key is used for:

Buy Enterprise Plan 

Returning the old response silently would hide a bug.

Instead:

Reject the request.

async function handleIdempotentRequest(key: string, payload: Payload) { const existing = await db.idempotencyKey.findUnique({ where: { key } }); if (existing) { // Same key reused check if payload matches if (existing.requestHash !== hashPayload(payload)) { return new Response( JSON.stringify({ error: 'Idempotency key reused with different parameters' }), { status: 422 } ); } // Same key, same payload return stored response return new Response(existing.responseBody, { status: existing.responseStatus }); } // First request process and store const result = await processPayment(payload); await db.idempotencyKey.create({ data: { key, requestHash: hashPayload(payload), responseBody: JSON.stringify(result), responseStatus: 200, resourceId: result.id, }, }); return new Response(JSON.stringify(result), { status: 201 }); } 

Good idempotency means:

Honor one declared intent consistently.

Idempotency Needs Product Design

Adding an Idempotency-Key header is necessary.

It is not sufficient.

Suppose a payment provider times out.

You do not know whether the charge succeeded.

Do not show:

Payment failed. 

Do not encourage another payment attempt.

Instead say:

Your payment is processing. Please do not retry. We'll confirm the status shortly.

That message reflects backend truth.

A healthy product state model looks like:

  • Processing

  • Succeeded

  • Failed

  • Requires Action

  • Reconciling

  • Reversed

// Product state machine for uncertain outcomes type PaymentState = | { status: 'processing'; message: 'Your payment is being processed.' } | { status: 'succeeded'; receiptUrl: string } | { status: 'failed'; reason: string } | { status: 'requires_action'; redirectUrl: string } | { status: 'reconciling'; message: 'Checking with payment provider...' }; function getPaymentDisplay(state: PaymentState) { switch (state.status) { case 'processing': return { title: 'Processing', message: state.message, showRetry: false, // Do NOT let user retry }; case 'succeeded': return { title: 'Done', message: 'Payment confirmed.', showRetry: false, }; case 'failed': return { title: 'Failed', message: state.reason, showRetry: true, // Safe to retry }; case 'reconciling': return { title: 'Checking', message: state.message, showRetry: false, // Do NOT let user retry }; } } 

Users can handle uncertainty.

They cannot handle duplicate charges.

The Real Contract

Think of every write endpoint as mapping:

One User Intent ↓ One Durable Outcome 

Not:

One HTTP Request ↓ One Outcome 

Because requests can be duplicated everywhere.

User Tap ↓ Browser Retry ↓ SDK Retry ↓ Load Balancer Retry ↓ Service Retry ↓ Queue Redelivery ↓ Webhook Replay 

Every layer may duplicate.

Your product should not.

// The contract: intent → outcome, not request → outcome interface IdempotentEndpoint { // Input: user intent, not raw HTTP intent: UserIntent; // Key: stable across retries of same intent key: string; // Output: always the same for same key outcome: Outcome; } // Implementation async function handleIntent(intent: UserIntent, key: string): Promise<Outcome> { const existing = await lookupByKey(key); if (existing) return existing.outcome; const outcome = await executeIntent(intent); await store(key, outcome); return outcome; } 

Operation Identity

Every system eventually invents one.

It may be called:

  • Idempotency Key

  • Client Token

  • Request ID

  • Operation ID

  • Payment Intent ID

  • Command ID

  • Event ID

The name matters less than the lifecycle.

It should be:

  • Unique enough

  • Scoped correctly

  • Persisted

  • Reused only for retries

  • Bound to a request fingerprint

  • Present in logs, traces, support tools, and downstream services

-- Idempotency record lifecycle CREATE TABLE idempotency_keys ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, key TEXT NOT NULL, request_hash TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', -- pending | succeeded | failed response JSONB, resource_id UUID, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), expires_at TIMESTAMPTZ NOT NULL, UNIQUE (tenant_id, key) ); -- Auto-cleanup: keys expire after 24 hours CREATE INDEX idx_idempotency_expires ON idempotency_keys (expires_at) WHERE status = 'pending'; 

Support teams love operation IDs.

Instead of asking:

"Can you explain what happened?"

they search one identifier.

Everyone sees the same story.

A Practical Implementation

1. Client Generates the Key

Generate it when the user begins the operation.

Not every retry.

// Client side key lifecycle function onCheckoutClick() { // One key per user action const key = crypto.randomUUID(); sessionStorage.setItem('checkout-key', key); api.post('/checkout', cart, { headers: { 'Idempotency-Key': key }, }); } function onRetryClick() { // Reuse existing key const key = sessionStorage.getItem('checkout-key'); if (!key) return redirectToCheckout(); api.post('/checkout', cart, { headers: { 'Idempotency-Key': key }, }); } 

2. Store an Idempotency Record

-- Server side record creation BEGIN; -- Check for existing key SELECT status, response FROM idempotency_keys WHERE tenant_id = $1 AND key = $2; -- If found: return stored response -- If not found: insert and process INSERT INTO idempotency_keys (tenant_id, key, request_hash, status, expires_at) VALUES ($1, $2, $3, 'processing', now() + interval '24 hours') ON CONFLICT (tenant_id, key) DO NOTHING; -- Process the request INSERT INTO orders (...) VALUES (...) RETURNING id; -- Update key with result UPDATE idempotency_keys SET status = 'succeeded', response = $4, resource_id = $5 WHERE tenant_id = $1 AND key = $2; COMMIT; 

3. Validate Key Reuse

// Server middleware idempotency check async function idempotencyGuard(req: Request, key: string) { const existing = await db.query( `SELECT status, response, request_hash FROM idempotency_keys WHERE tenant_id = $1 AND key = $2`, [req.tenantId, key] ); if (existing) { if (existing.status === 'succeeded' || existing.status === 'failed') { // Finished return stored response return new Response(existing.response, { status: existing.status === 'succeeded' ? 200 : 500 }); } if (existing.status === 'processing') { // Still running return 409 or 202 depending on API contract return new Response(JSON.stringify({ status: 'processing' }), { status: 409 }); } } // New request store and continue const requestHash = hashPayload(req.body); if (existing && existing.requestHash !== requestHash) { return new Response( JSON.stringify({ error: 'Key reused with different payload' }), { status: 422 } ); } return null; // Continue to handler } 

4. Make Writes Atomic

Bad:

Save key ↓ Order creation fails 

Retry now sees the key.

No order exists.

Also bad:

Create order ↓ Save key fails 

Retry creates another order.

Use one database transaction.

// Atomic idempotency + write async function createOrderWithIdempotency(key: string, order: OrderData) { return db.transaction(async (tx) => { // 1. Insert key atomically const inserted = await tx.query( `INSERT INTO idempotency_keys (tenant_id, key, request_hash, status, expires_at) VALUES ($1, $2, $3, 'processing', now() + interval '24 hours') ON CONFLICT (tenant_id, key) DO NOTHING RETURNING id`, [order.tenantId, key, hashPayload(order)] ); if (!inserted) { // Key exists fetch and return stored result const existing = await tx.query( `SELECT status, response FROM idempotency_keys WHERE tenant_id = $1 AND key = $2`, [order.tenantId, key] ); return JSON.parse(existing.response); } // 2. Create order in same transaction const result = await tx.query( `INSERT INTO orders (...) VALUES (...) RETURNING *`, [order] ); // 3. Update key with result await tx.query( Source:https://dev.to/shubham399/idempotency-is-a-product-feature-3ofk

Related posts