API Idempotency Design: Making Retries Safe
Network timeouts, double-clicks, and auto-retries can replay the same request many times. Idempotency ensures an operation run N times yields consistent results. Covers the essence (GET naturally idempotent vs POST not), implementation (Idempotency Key + dedup table), idempotency for payments and ordering, and concurrency coordination.
Key Points
- Topic Focus: TypeScript、DevOps
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
API Idempotency Design: Making Retries Safe
"The network is unreliable, users are anxious, and retries are automatic — put these three together and your API must be idempotent."
As a backend engineer, you have surely been through this kind of post-mortem:
- The user clicked "Pay" once, and was charged twice.
- The gateway timed out, the client retried automatically, and two orders were placed.
- The message queue redelivered a message, and the same points were added twice.
The root cause of all these problems is the same: the same operation was executed multiple times, and your system did not guarantee that "executing multiple times equals executing once." This is exactly what idempotency solves.
Idempotency is not icing on the cake; it is the baseline of distributed systems. Any externally exposed write API without idempotency guarantees is running naked. This article thoroughly explains the essence of idempotency, implementation methods, key scenarios, and how it coordinates with concurrency.
1. What Is Idempotency: Starting from the Trouble of "Duplication"
Mathematically, the definition of idempotent is simple:
If executing an operation once produces the same effect as executing it multiple times, the operation is idempotent.
More formally: for a function f, if f(f(x)) = f(x), then f is idempotent.
Mapped to the API world, this translates to: the same request sent once and sent ten times produces the same change to system state, and the result returned to the client is also consistent (or at least produces no new side effects).
Note the two keywords here:
- The change to system state is the same: not "returns the same result," but "the side effects on the system are consistent." A query request may return different content each time because the time differs, but it doesn't change state, so it is idempotent.
- The boundary of side effects must be clear: deducting 100 yuan twice and deducting 200 yuan — that's not idempotency. Deducting 100 yuan twice and still only deducting 100 yuan — that's idempotency.
Why are distributed systems so obsessed with idempotency? Because "duplication" is the norm in this world, not the exception:
- The network is unreliable: the client sends a request, the server finishes processing, but the response is lost on the way back. The client doesn't receive the response, assumes it failed, and retries automatically.
- Users are unreliable: the user waits 3 seconds without seeing feedback and clicks "Submit" again.
- Middleware is unreliable: the At-Least-Once semantics of message queues, gateway timeout retries, and service mesh retry policies can all cause the same request to appear multiple times.
A mature system must assume: any request may be sent more than once. Design based on this assumption, rather than hoping "the client will do it right."
In a word: idempotency doesn't eliminate "duplication" — it makes "duplication" harmless.
2. The Natural Divide of HTTP Methods
Discussing API idempotency inevitably involves the HTTP methods themselves. RFC 7231 defines the semantics of each method, and some methods are naturally idempotent while others are naturally not.
| Method | Safe | Idempotent | Description |
|---|---|---|---|
| GET | Yes | Yes | Read-only, no side effects |
| HEAD | Yes | Yes | Same as GET, only returns headers |
| OPTIONS | Yes | Yes | Preflight / queries supported methods |
| PUT | No | Yes | Replaces the target resource with the request body; multiple replacements produce the same result |
| DELETE | No | Yes | Deletes a resource; deleting once and ten times both result in "does not exist" |
| POST | No | No | Creates a resource / triggers an action; each call may produce new side effects |
| PATCH | No | No | Partial update; idempotency is not guaranteed by semantics (depends on implementation) |
Many people are confused: why are PUT and DELETE idempotent? They clearly change state.
The key is that "idempotency" looks at the final state after multiple executions, not "whether there are side effects."
PUT /users/123 {name: "Alice"}: no matter how many times you execute it, user 123's name ends up as Alice. The first execution changes state; subsequent executions "overwrite with the same value" and produce no new state change. Hence idempotent.DELETE /users/123: the first execution deletes the user; subsequent executions find the user already gone and do nothing. The final state is always "user does not exist." Hence idempotent.POST /users {name: "Alice"}: each execution creates a new user; ten executions produce ten Alices. The state keeps changing, hence not idempotent.
With this understanding, the first principle of API design becomes clear:
Use the right HTTP method, and you get half of idempotency for free.
If you can express "replace" semantics with PUT, don't use POST. If you can express "delete" with DELETE, don't simulate it with POST. Many people like to POST everything, citing "flexibility," but the price is abandoning the idempotency guarantees built into HTTP methods and reimplementing all retry logic themselves.
But reality is harsh: a large number of business operations are inherently non-idempotent POSTs — placing orders, payments, transfers, issuing coupons. These are the real battlegrounds of idempotency design. HTTP methods can't help you; you have to do it yourself.
PATCH is a gray area. If PATCH is implemented as "set a field to a value" ({op: "replace"}), it is idempotent; if it is "increment a field by a value" ({op: "increment"}), it is not idempotent. That's why the RFC says PATCH "does not guarantee idempotency" — it depends on your semantics. Keep this in mind when designing PATCH APIs.
3. Idempotency Key: Letting POST Retry Safely Too
Since POST is inherently non-idempotent, how do you make it "idempotent"? The industry's standard answer is: Idempotency Key.
The core idea in one sentence: the client generates a unique ID for each "logical request" and sends the request with that ID; the server remembers this ID and its corresponding result, and when the same ID comes again, it directly returns the remembered result without executing the real side effect again.
Several details here are worth expanding on:
First, who generates the ID?
It must be generated by the client, and it must carry semantics about "the retried request is the same request." If the client sends a "place order" request and it times out, the client retries — these two must carry the same ID, so the server can recognize "this is the same order, don't process it twice." If each retry uses a different ID, the mechanism is effectively useless.
// Client: always use the same key for the same business operation
async function submitOrder(order: Order) {
const idempotencyKey = order.clientOrderId // Use the business-level unique ID
// Or crypto.randomUUID(), but it must be saved on the client and reused on retry
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(order),
})
// On timeout retry, still use the same idempotencyKey
return withRetry(() => submitOrder(order), { key: idempotencyKey })
}
Second, how does the server "remember"?
Either store it in a database (a dedup table) or in a cache (Redis). Each has its pros and cons, discussed in detail later.
Third, what is remembered: the "result" or "being processed"?
This is an easily overlooked point. If a request takes 2 seconds to process and the client times out and retries after 1 second, by the time the second request arrives the first one hasn't finished yet — at this point the server can't simply "return a cached result," because there is no result to return yet. So the idempotency record needs a state machine: processing → done. When the second request sees processing, it either waits (synchronous blocking) or directly returns "processing" (async polling), but it must absolutely not start the real processing again.
type IdempotencyRecord = {
key: string
status: 'processing' | 'done' | 'error'
result?: unknown
createdAt: number
expiresAt: number
}
Together, these three points form the idempotency model adopted by all mature API platforms such as Stripe, PayPal, and AWS. Its core is: decouple "request" from "side effect" — a request can arrive N times, but the side effect happens only once.
4. Deduplication Table: A Concrete Implementation of Idempotency
With the principles clear, let's look at the most classic implementation: the deduplication table.
The idea is straightforward: create a table; each time a request is processed, store its idempotency key and processing result. Next time the same key comes, look it up first, and return the cached result on a hit.
CREATE TABLE idempotency_records (
idempotency_key VARCHAR(128) PRIMARY KEY,
request_method VARCHAR(10) NOT NULL,
request_path VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL, -- processing / done / error
response_status INT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_idempotency_expires ON idempotency_records(expires_at);
A few design points:
- The primary key is idempotency_key: use the database's unique constraint to directly handle concurrency, as explained later.
- Record the request method and path: the same key has different semantics for different endpoints and must be distinguished. Stripe's approach is to use key + method + path + params together as the dedup dimension.
- The status field: supports the state machine for async processing, preventing "processing" from being triggered repeatedly.
- expires_at: idempotency records can't be kept forever; they must be cleaned up periodically.
Server-side processing logic:
async function withIdempotency<T>(
req: Request,
handler: () => Promise<T>
): Promise<{ status: number; body: T | { error: string } }> {
const key = req.headers.get('Idempotency-Key')
if (!key) {
// Requests without a key are handled normally (no idempotency protection)
return { status: 200, body: await handler() }
}
const repo = new IdempotencyRepository(db)
// 1. Try to insert a processing record.
// Leverage the unique constraint: under concurrency, only one request can insert successfully.
const inserted = await repo.tryInsert({
key,
status: 'processing',
requestMethod: req.method,
requestPath: new URL(req.url).pathname,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24h TTL
})
if (!inserted) {
// 2. Record already exists; read its status.
const existing = await repo.findByKey(key)
if (!existing) {
// Edge case: just deleted by a concurrent request; tell the client to retry with a new key.
return { status: 409, body: { error: 'conflict, please retry with a new key' } }
}
if (existing.status === 'processing') {
// 3. Still processing: return 409 so the client retries later (or polls).
return { status: 409, body: { error: 'request is being processed' } }
}
if (existing.status === 'done') {
// 4. Done: directly return the cached result (key!).
return { status: existing.responseStatus!, body: existing.responseBody as T }
}
// 5. Last attempt failed: allow retrying with the same key.
// Reset to processing and continue.
await repo.renewAsProcessing(key)
}
// 6. Actually execute the business logic.
try {
const result = await handler()
await repo.markDone(key, 200, result)
return { status: 200, body: result }
} catch (err) {
await repo.markError(key)
throw err
}
}
This code embodies the essence of the dedup table: successful insert = acquiring the "right to execute"; failed insert = go through cache or queue. This uses the database's atomicity to coordinate concurrency, which is more robust than application-level locking.
Dedup Table vs Redis Cache
Many people ask: why not just use Redis, why write a table?
My view is: for idempotency in critical business, prefer database tables; for non-critical business, caching is fine. Reasons:
- Durability: database tables survive service restarts, while Redis may lose data by default. Once idempotency fails, it's a duplicate charge — an extremely high cost.
- Transactional consistency: dedup records can live in the same transaction as the business write (deduction + idempotency record commit together), fundamentally eliminating the inconsistency of "charged but idempotency record not written." Redis cannot do this.
- Observability: records in a table are naturally audit logs, making troubleshooting convenient.
Redis's advantage is performance and automatic TTL expiration. It fits scenarios where "duplicates may occur within a short window but no long-term traceability is needed," such as rate limiting and debouncing. For payment-grade idempotency, stick with a table.
Of course, the two can also be combined: Redis as the first layer for fast deduplication (blocking most duplicates) and the database table as the second layer for persistent fallback. This is a common approach in large systems.
5. Payment/Ordering: The Main Battleground of Idempotency
Having covered the general approach, let's look at the most sensitive scenarios in business: ordering and payment. Once idempotency fails here, it's a direct financial loss, so it's the top priority.
Ordering: Business Unique ID + Database Unique Constraint
The essence of ordering is "creating an order." We must guarantee: if a user submits twice due to network issues or itchy fingers, only one order is produced.
The safest approach is not to rely on an extra idempotency table, but to make the order itself naturally unique. Before submitting, the client generates a client_order_id (e.g., a UUID) as part of the order. The server creates a unique constraint on client_order_id + user_id in the orders table:
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
client_order_id VARCHAR(64) NOT NULL, -- Client-generated business unique ID
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, client_order_id) -- Key: database unique constraint as fallback
);
Server-side logic:
async function createOrder(userId: number, clientOrderId: string, items: Item[]) {
return await db.transaction(async (tx) => {
try {
const order = await tx.orders.create({
data: { userId, clientOrderId, amount: calcAmount(items), status: 'pending' },
})
// Deduct inventory, generate snapshots, etc.
await tx.inventory.decrement(items)
return { status: 'created', order }
} catch (err) {
if (isUniqueViolation(err)) {
// Unique constraint triggered: an order with the same clientOrderId already exists.
const existing = await tx.orders.findFirst({
where: { userId, clientOrderId },
})
return { status: 'already_exists', order: existing }
}
throw err
}
})
}
Note that it returns { status: 'already_exists' } instead of throwing — this is a detail of idempotency design: duplicate requests should be transparent to the client, telling it "your order is already there," rather than throwing an error and crashing it.
Why not use the general idempotency table described earlier? Because for ordering, the orders table itself can serve as the "dedup table," and there's no need to maintain an extra one. Don't introduce additional mechanisms for problems solvable with business uniqueness.
Payment: Idempotency Key + Dedup Table
Payment is more complex than ordering because:
- Payment operations don't create an entity: payment "triggers an external side effect" (calling a bank/third-party channel) and has no table of its own to act as a dedup table.
- Results may be delayed: payment channel callbacks may take seconds to minutes to arrive.
- Amounts must be exact: duplicate payment is directly a money issue.
So the payment scenario must use independent idempotency records:
async function charge(req: ChargeRequest): Promise<ChargeResult> {
return await withIdempotency(req, async () => {
// 1. Business validation
const order = await db.orders.findById(req.orderId)
if (order.status === 'paid') {
return { code: 'already_paid', orderId: order.id }
}
// 2. Call the payment channel (external side effect)
const channelResp = await paymentChannel.createCharge({
orderId: order.id,
amount: order.amount,
})
// 3. Update order status
await db.orders.update(order.id, { status: 'paying', channelChargeId: channelResp.id })
return { code: 'charging', orderId: order.id, channelChargeId: channelResp.id }
})
}
withIdempotency is the same dedup-table logic from the previous section. Its value here is: the payment channel is called only once. Even if the client retries the same Idempotency-Key ten times, only one request actually hits the third-party channel — which is exactly what we want.
The iron law of payment idempotency: calls with external side effects must be wrapped in idempotency protection. When you call a bank API to deduct a payment, there's no room for retry; one duplicate is one duplicate charge.
A Hidden Trap: Idempotency Is Not Only at the Outer Layer
A common mistake among newcomers: they wrap only the outermost Controller with withIdempotency, but internally call another non-idempotent service. As a result, the outer dedup blocks duplicate requests, but the internal service is triggered multiple times (e.g., there's another retry mechanism inside, or another consumer).
The correct mental model is: every layer must think "if this layer is called repeatedly, what happens?" The outer layer can block client retries, but not the redelivery of internal message queues, repeated execution of scheduled tasks, or repeated arrival of callbacks. Every critical side effect must have its own idempotency protection.
6. Idempotency Under Concurrency: Locks and Unique Constraints
The most easily underestimated difficulty in idempotency design is concurrency.
"The same request comes twice" sounds sequential: the first is processed, the second arrives, the lookup hits, and it returns. But in the real world, "twice" often means arriving at the same time — the client retry and the original request hit different server instances almost simultaneously. At this point, if you just "look up first and process if not found," both requests find "not found" and both process — idempotency completely fails.
Take a look at an incorrect implementation:
// ❌ Counterexample: lookup-then-write, a classic TOCTOU vulnerability
async function badIdempotency(key: string, handler: () => Promise<void>) {
const exists = await repo.findByKey(key)
if (exists) {
return exists.result
}
// Both concurrent requests reach here!
const result = await handler()
await repo.save(key, result)
return result
}
This is the classic TOCTOU (Time of Check to Time of Use) problem. It will definitely break under high concurrency.
There are two correct approaches:
Option A: Database Unique Constraint (Recommended)
Treat "insert" as the atomic operation for acquiring the right to execute. Use INSERT ... ON CONFLICT or catch unique constraint violations:
async function safeIdempotency(key: string, handler: () => Promise<Result>) {
try {
// Atomically insert a processing record. Success = acquired the right to execute.
await db.none(
`INSERT INTO idempotency_records(key, status, expires_at)
VALUES($1, 'processing', now() + interval '24 hours')`,
[key]
)
} catch (err) {
if (isUniqueViolation(err)) {
// Record already exists: go through the cache/queue logic (same as the previous section).
return await handleExistingRecord(key)
}
throw err
}
// Right to execute acquired; run the business logic.
const result = await handler()
await db.none(`UPDATE idempotency_records SET status='done', result=$2 WHERE key=$1`, [key, result])
return result
}
The essence of this path: no application-level lock; let the database's atomic constraint act as the referee. Databases naturally serialize writes to unique constraints; no matter how many concurrent requests there are, only one can INSERT successfully. Simple, reliable, and cross-instance (works across multiple service nodes).
Option B: Distributed Lock
If the business logic can't be expressed simply by a database constraint (e.g., it needs to call a long chain of external services with complex intermediate states), you can use a Redis distributed lock:
async function lockBasedIdempotency(key: string, handler: () => Promise<Result>) {
const lockKey = `idempotency:${key}`
const lock = await redis.set(lockKey, '1', 'PX', 30000, 'NX') // SETNX, 30s TTL
if (!lock) {
// Didn't get the lock: another request is processing the same key.
return await waitForResult(key) // Poll the idempotency record, waiting for it to become done.
}
try {
// Double safety: check once more, since the previous lock holder may have just released while the record is already done.
const existing = await repo.findByKey(key)
if (existing?.status === 'done') return existing.result
const result = await handler()
await repo.save(key, result)
return result
} finally {
await redis.del(lockKey)
}
}
The lock approach requires attention to lock release (avoiding lock leaks) and the edge case where "in-lock execution time exceeds the lock TTL." For production, it's recommended to use Redlock or strongly consistent locks based on etcd/Zookeeper.
My View
If a unique constraint works, don't use a distributed lock. Unique constraints are declarative, transaction-safe, and nearly zero-ops; distributed locks are imperative, bug-prone, and require careful handling of various edge cases. I've seen far too many production incidents whose root cause was "the distributed lock was used incorrectly."
Only consider distributed locks when the business process involves long transactions across multiple external systems and truly can't be expressed via database constraints. Even then, it's recommended to use lock + unique constraint as double insurance: the lock blocks most concurrency, and the unique constraint provides a fallback for the extreme case where the lock fails.
7. Expiration Strategy: Idempotency Records Are Not Forever
Idempotency records can't be kept forever, for three reasons:
- Storage cost: every write request leaves a trace, and the table bloats indefinitely.
- Semantic staleness: for a request from two weeks ago, the business context may have long changed (e.g., the order was refunded, the product was taken off the shelves). Retrying with an old key would instead trigger "restoring a state that shouldn't be restored," introducing new bugs.
- Security boundary: keeping idempotency records indefinitely means an attacker who obtains a key can replay the response long-term.
So idempotency records must have a TTL (Time To Live).
How Long Should the TTL Be?
There's no standard answer; it depends on the business's retry characteristics:
- General APIs: 24 hours is Stripe's default and the de facto standard for most platforms. It covers all normal retries, manual investigations, and client bug-fix cycles within a day.
- Payment/reconciliation: can be extended to 7–30 days, since operations like reconciliation and order compensation may occur days later.
- Config updates: a few minutes to a few hours is enough, since the retry window for config-type operations is short.
Don't keep them indefinitely. Some people think "the longer the safer," but in practice, beyond a certain window, the marginal benefit of keeping records longer is nearly zero, while the marginal cost of storage and complexity grows linearly.
How to Clean Up?
Two approaches:
Approach 1: Redis TTL (auto expiration)
If idempotency records are stored in Redis, just use EXPIRE for automatic cleanup.
await redis.set(key, JSON.stringify(record), 'EX', 24 * 60 * 60) // 24h
Approach 2: Database scheduled task (periodic deletion)
If stored in a database, rely on a scheduled task to clean up expired records:
// Runs every day at midnight
async function cleanupExpiredIdempotencyRecords() {
const deleted = await db.result(
`DELETE FROM idempotency_records WHERE expires_at < now()`
)
log.info(`cleaned up ${deleted.rowCount} expired idempotency records`)
}
Combined with an index on expires_at, this cleanup is efficient.
Lookups must also include an expiration check — to avoid a key being incorrectly hit during the "expired but not yet cleaned up" window:
async function findByKey(key: string) {
return await db.oneOrNone(
`SELECT * FROM idempotency_records
WHERE key = $1 AND expires_at > now()`,
[key]
)
}
The Problem of Key Reuse
After the TTL expires, the same key can be used again for new requests. Here's a pitfall: the client must not reuse an expired key.
If the client's code hardcodes the key or generates it from an auto-increment ID, after some time the same key may be assigned to two semantically different requests, causing the bizarre bug of "a new request submitted with an old key returns the old result." So the best practice for the client to generate keys is: use a new random UUID for each new logical request, and only reuse it within its own "retry" flow.
8. In Practice: Learning Idempotency Design from Stripe
Having covered so much theory and our own implementations, let's look at how the industry benchmark does it. Stripe's Idempotency-Key design is a recognized textbook-grade practice that nearly every later payment platform imitates. It's worth dissecting point by point.
Stripe's Rules
- The client passes a string of no more than 255 characters via the
Idempotency-KeyHTTP header; a UUID is recommended. - The server uses key + request parameters (method, path, body) together as the dedup dimension. The same key with different request parameters is rejected (returns 400) to prevent misuse.
- Results are cached for 24 hours; after 24 hours, the same key can be reused.
- Supports automatic retry: Stripe's official SDK has built-in automatic retry that reuses the same key, completely transparent to developers.
- Covers all write endpoints: not just payments, including refunds, customer creation, transfers, etc. — all POSTs support it.
What Did It Get Right?
First, it turns idempotency into a protocol rather than a convention.
For many teams, idempotency is an oral convention like "we'll do our best to guarantee," and when something goes wrong, they point fingers. Stripe turned it into a standard protocol via an HTTP header that the client must send and the server must handle, written explicitly into the API docs. That's engineering.
Second, it's built into the SDK, transparent to developers.
// The Stripe SDK handles idempotency automatically
const charge = await stripe.charges.create({
amount: 1000,
currency: 'usd',
source: 'tok_visa',
}, {
idempotencyKey: 'client-generated-uuid', // Just provide the key
})
Developers only need to provide an idempotencyKey; the SDK handles timeouts, retries, and key reuse itself. This is good developer experience. Converge complexity at the framework layer; don't make every business developer reimplement it.
Third, validate key + parameters together.
Many people skip this step. If a client bug causes the same key to be used on two different requests (e.g., first for placing an order, then misused for a refund), without this check, the "result of the order placement would be returned to the refund request." Stripe intercepts this misuse at the source by validating parameter consistency.
// Server-side validation: after a key hit, compare the current request parameters with the cached ones.
async function verifyParams(key: string, req: Request) {
const cached = await repo.findByKey(key)
if (cached && !paramsEqual(cached.params, await serialize(req))) {
throw new BadRequestError(
`idempotency key was used for a different request`
)
}
}
Fourth, clear failure semantics.
Stripe specifies: if the original request ultimately fails, the key does not "lock in the failed result"; the client can safely retry with the same key. Records in the failed state are cleaned up after a short time, allowing retry. This is counterintuitive (many implementations cache the failed result too), but it better matches the developer's mental model: "of course you should be able to retry on failure."
The Right Way to Copy the Homework
If your system exposes APIs externally, or has cross-service write calls internally, just copy Stripe's pattern directly:
- Define an
Idempotency-Keyheader and require the client to generate a UUID. - Build a dedup table on the server, recording key + parameters + status + result.
- TTL of 24 hours (adjust to your business).
- Provide an SDK / middleware so upstream developers don't have to worry about details.
- Write it into the API docs, explicitly telling users "this is supported."
Don't underestimate the "write it into the docs" step. Many teams have an idempotency mechanism internally, but no one knows about it, and business developers still write their own. A mechanism that isn't widely adopted is equivalent to no mechanism at all.
Summary
- The essence of idempotency: executing the same operation multiple times produces the same state change as executing it once. It doesn't eliminate duplication; it makes duplication harmless.
- HTTP methods bring half of idempotency for free: GET/PUT/DELETE are naturally idempotent; POST/PATCH are not guaranteed. Using the right method is the first step in design.
- Idempotency for POST-type operations relies on the Idempotency Key: the client generates a unique key, the server uses a dedup table/cache, and duplicate requests return the cached result. This is the industry standard.
- Dedup table vs Redis: for critical business (payment/ordering), prefer database tables — durable, auditable, transactional; for non-critical scenarios, use Redis for speed.
- Two paths for concurrency idempotency: prefer database unique constraints (declarative, reliable); only use distributed locks when business complexity demands it, and double insurance is recommended.
- An expiration strategy is mandatory: 24 hours is a common TTL; database scheduled cleanup + expiration checks on lookups. Clients must not reuse keys across logical requests.
- Learn from Stripe: make idempotency a protocol (standard header + parameter validation), bake it into the SDK, and write it into the docs. Only engineered mechanisms are reliable.
Key insight: In a world where the network, users, and middleware are all untrustworthy, idempotency isn't an optimization — it's the passing grade for an API. Every write API must assume by default that it will be retried ten times — and then make it able to withstand that.
Only APIs that can withstand duplication are production-ready APIs.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.
Article
What Value Do Server Components Offer Content Sites?
Technical trends and engineering practices around Next.js and React, from an independent developer's perspective.