What is idempotence?
Idempotence is a property of an operation where running it multiple times produces the same final state as running it once.
Theory
TL;DR
- Painting a wall red: first coat makes it red, second coat still leaves it red. That is idempotence.
PUT /balance/1 {balance: 200}called three times always results inbalance: 200.POST /charge {amount: 100}called three times charges $300 total. Not idempotent.- Decision rule: use GET, PUT, DELETE for retries. Use POST only when you handle duplicates explicitly.
- In distributed systems, retries are inevitable. Idempotent operations make them safe.
Quick example
// PUT is idempotent - sets state to an absolute value
app.put('/balance/:id', (req, res) => {
const newBalance = req.body.balance; // {balance: 200}
userBalance = newBalance; // Overwrites, same result every time
res.json({ balance: userBalance });
});
// POST is not idempotent - accumulates state
app.post('/charge', (req, res) => {
total += req.body.amount; // +100 each call: 100, 200, 300...
res.json({ total });
});
// Three identical PUT calls → balance: 200, 200, 200
// Three identical POST calls → total: 100, 200, 300PUT overwrites. POST appends. That difference is the whole point.
What "same result" actually means
Idempotence is about the final state, not the HTTP response code. A DELETE on a resource that no longer exists might return 404 on the second call, but the state is identical: the resource is gone. Both calls achieved the same outcome.
RFC 9110 defines idempotent methods as those where "the intended effect on the server of multiple identical requests is the same as for a single such request." Notice it says "intended effect," not "identical response."
HTTP methods and idempotence
| Method | Idempotent | Safe | Notes |
|---|---|---|---|
| GET | Yes | Yes | Read-only, no state change |
| HEAD | Yes | Yes | Same as GET, no body |
| PUT | Yes | No | Full overwrite, same payload equals same state |
| DELETE | Yes | No | Resource gone after first call |
| POST | No | No | Creates new resource each time |
| PATCH | Depends | No | Increment-based operations break it |
Safe means the operation has no side effects at all. Idempotent means repeating it does not add new effects. Every safe method is idempotent, but not every idempotent method is safe. PUT changes state, but repeating it does not change state further.
The PATCH trap
PATCH is where most engineers get this wrong. PATCH can be idempotent or not, depending on implementation. I have seen increment-based PATCH methods pass code review in production APIs, only to cause subtle data corruption under retry conditions.
// WRONG: this PATCH breaks idempotence
app.patch('/user/:id', async (req, res) => {
// On retry: visits becomes 2, then 3, then 4
await db.user.update({ where: { id }, data: { visits: { increment: 1 } } });
});
// Better: use PUT with an absolute value
app.put('/user/:id', async (req, res) => {
const updates = req.body; // { visits: 5 } - absolute, not relative
await db.user.update({ where: { id }, data: updates });
});The problem with increment: 1 is that it is a relative operation. The same request with the same payload changes state differently depending on when you call it. That makes retries dangerous.
Idempotency keys for POST
Sometimes POST is the right method but you still need retry safety. That is what idempotency keys solve.
// Stripe-style idempotency key on POST
app.post('/charge', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key']; // UUID from client
const existing = await redis.get(`idem:${idempotencyKey}`);
if (existing) return res.json(JSON.parse(existing)); // Return cached result
const result = await chargeCustomer(req.body);
// Store result tied to this key for 24h
await redis.setex(`idem:${idempotencyKey}`, 86400, JSON.stringify(result));
res.json(result);
});Stripe does exactly this for /v1/charges. The client generates a UUID, includes it as a header, and the server deduplicates. First call processes the charge. Every retry gets the original response back. No double-charge.
Common mistakes
Assuming PATCH is always idempotent. The HTTP spec says PATCH can be idempotent if implemented that way, but nothing forces it. Increment-based updates are not idempotent by definition.
Thinking 204 vs 404 breaks idempotence on DELETE. A second DELETE returning 404 instead of 204 does not violate idempotence. The resource is absent in both cases. The intended state matches.
Database inserts without unique constraints:
// Wrong: retry creates duplicate rows in payments table
db.query('INSERT INTO payments VALUES (?, ?)', [paymentId, amount]);
// Right: PostgreSQL skips the duplicate silently
db.query(
'INSERT INTO payments VALUES (?, ?) ON CONFLICT (payment_id) DO NOTHING',
[paymentId, amount]
);This trips up Kafka consumers constantly. A message gets processed, the consumer crashes before committing the offset, and on restart it processes the same message again. Without ON CONFLICT, you get duplicate rows.
Relying on PUT in an eventually-consistent system. With replica lag, two identical PUT requests hitting different nodes can produce temporarily different states. The system converges, but during that window you cannot guarantee idempotence at the application level. CockroachDB and DynamoDB expose conditional writes for this reason.
Treating GET as always producing stable results. GET is idempotent by design, but /users?offset=10&limit=5 can return different records if new users were inserted between retries. The operation is idempotent; the results are not stable. Cache keys need to include the full URL.
Real-world usage
- Stripe API: every
POST /v1/chargesaccepts anIdempotency-Keyheader. Retries within 24 hours return the original response. - Kubernetes:
kubectl delete pod my-podcalled twice ends in the same state. The second call gets 404, and that is expected. - React Query:
useMutationwithmutationKeyallows the library to deduplicate in-flight requests. - AWS DynamoDB:
PutItemoverwrites the entire item. Same payload, same result. - PostgreSQL:
INSERT ... ON CONFLICT DO NOTHINGandUPSERTare the standard tools for idempotent writes in high-volume systems.
Follow-up questions
Q: Which HTTP methods are idempotent per RFC 9110?
A: GET, HEAD, PUT, and DELETE. POST and PATCH are not idempotent by default, though PATCH can be designed to be idempotent if it uses absolute values instead of relative operations.
Q: What is the difference between idempotent and safe in HTTP?
A: Safe means no side effects at all (read-only). Idempotent means repeated calls do not add new effects beyond the first. GET is both. PUT is idempotent but not safe. POST is neither.
Q: How do you make POST safe for retries?
A: Add an idempotency key (UUID in the request header), store the result keyed to that UUID on the server side, and return the stored result on any subsequent retry with the same key. Stripe, Braintree, and most payment processors use this pattern.
Q: Can a PUT ever fail idempotence in a distributed system?
A: Yes. With eventual consistency, replica lag means two identical PUTs hitting different nodes can produce temporarily different states. Conditional writes using ETags or compare-and-swap operations solve this at the cost of added complexity.
Q (senior): Design an idempotent counter increment for a sharded database.
A: Store the current value on the client and use PUT with the absolute target value instead of a relative increment. If you must use an increment, wrap it in a transaction with a unique operation ID: check whether the ID was already applied, skip if yes, apply if no. This is the outbox pattern applied to counters.
Examples
Basic: PUT vs POST on retry
const express = require('express');
const app = express();
app.use(express.json());
let userBalance = 100;
const charges = [];
// Idempotent: always sets state to the given value
app.put('/balance/:id', (req, res) => {
userBalance = req.body.balance;
res.json({ balance: userBalance });
});
// Not idempotent: records a new charge on every call
app.post('/charge', (req, res) => {
charges.push({ amount: req.body.amount, at: Date.now() });
res.json({ charges });
});
// Three PUT /balance/1 {balance: 200} → always {balance: 200}
// Three POST /charge {amount: 100} → [{...}, {...}, {...}], total 300PUT sets a target state. POST records an event. If the network retries a PUT, no damage done. If it retries a POST, you charge the customer three times.
Intermediate: Idempotency key pattern on POST
const express = require('express');
const app = express();
app.use(express.json());
const processedKeys = new Map(); // In production: Redis with TTL
app.post('/payment', async (req, res) => {
const idempotencyKey = req.headers['x-idempotency-key'];
if (!idempotencyKey) {
return res.status(400).json({ error: 'x-idempotency-key header required' });
}
// Return cached result if we already processed this key
if (processedKeys.has(idempotencyKey)) {
return res.json(processedKeys.get(idempotencyKey));
}
const result = {
paymentId: `pay_${Date.now()}`,
amount: req.body.amount,
status: 'charged'
};
processedKeys.set(idempotencyKey, result);
res.json(result);
});
// Client: POST /payment with header x-idempotency-key: uuid-abc-123
// Retry with same key → same paymentId, no second charge
// New key → new payment recordThe client owns the idempotency key. If the client does not know whether the first request reached the server, it sends the same key again. The server handles deduplication. This is the whole contract.
Senior: Idempotent consumer in a message queue
// Kafka consumer - messages can be delivered more than once (at-least-once)
async function processPaymentMessage(message) {
const { paymentId, amount, userId } = JSON.parse(message.value);
// Use paymentId as a natural idempotency key
const existing = await db.payments.findUnique({ where: { paymentId } });
if (existing) {
console.log(`Skipping duplicate: ${paymentId}`);
return; // Already processed, commit offset and move on
}
await db.$transaction(async (tx) => {
await tx.payments.create({ data: { paymentId, amount, userId } });
await tx.users.update({
where: { id: userId },
data: { balance: { decrement: amount } }
});
});
}
// Without this check: at-least-once delivery = potential double charges
// With this check: at-least-once delivery becomes effectively-once processingThis pattern shows up in every production system using Kafka, SQS, or RabbitMQ. The at-least-once delivery guarantee means your consumer will see the same message twice eventually. The idempotency check is what separates a system that handles this gracefully from one that sends customers two receipts.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.