Skip to main content

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 in balance: 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

javascript
// 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, 300

PUT 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

MethodIdempotentSafeNotes
GETYesYesRead-only, no state change
HEADYesYesSame as GET, no body
PUTYesNoFull overwrite, same payload equals same state
DELETEYesNoResource gone after first call
POSTNoNoCreates new resource each time
PATCHDependsNoIncrement-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.

javascript
// 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.

javascript
// 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:

javascript
// 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/charges accepts an Idempotency-Key header. Retries within 24 hours return the original response.
  • Kubernetes: kubectl delete pod my-pod called twice ends in the same state. The second call gets 404, and that is expected.
  • React Query: useMutation with mutationKey allows the library to deduplicate in-flight requests.
  • AWS DynamoDB: PutItem overwrites the entire item. Same payload, same result.
  • PostgreSQL: INSERT ... ON CONFLICT DO NOTHING and UPSERT are 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

javascript
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 300

PUT 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

javascript
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 record

The 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

javascript
// 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 processing

This 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?