Idempotency in APIs Explained (with Node.js Examples)

In this post I will explain what idempotency is, why it matters when you build APIs, and how to add it to a Node.js API with idempotency keys. The short answer: an operation is idempotent when running it many times has the same effect as running it once. Setting a user's email to [email protected] is idempotent; charging a credit card is not. In HTTP, GET, PUT and DELETE are idempotent by definition, while POST and PATCH are not, so for those the client sends a unique Idempotency-Key header and the server returns the saved result when the same key arrives again.

The examples were tested with Node.js 24.21 (the current LTS line), Express 5.3, the redis package 6.3 and Redis 8.10, on Windows 11.
What does idempotent mean?
The word comes from math. A function f is idempotent when f(f(x)) is the same as f(x): applying it again changes nothing. Math.abs() is a good example:
Math.abs(-5) // 5
Math.abs(Math.abs(-5)) // still 5
In software we use the word for operations that change state. Compare these two:
user.balance = 100 // idempotent: run it 3 times, the balance is 100
user.balance += 100 // not idempotent: run it 3 times, you added 300
The first one sets a value, so repeating it is harmless. The second one adds to whatever is there, so every repetition changes the result.
Note: Idempotency is about the effect on the server. The response can change between calls, as we'll see with DELETE.
Why idempotency matters in APIs
Sometimes a client sends a request, the server processes it, and the response gets lost on the way back: a timeout, a dropped connection, a phone going into a tunnel. From the client's side, there's no way to know whether the request never arrived or whether the server did the work and only the response was lost.
The natural thing to do is to retry. If the operation is idempotent, the retry is safe. If it isn't, you may do the work twice:

That's how a customer gets charged twice or an order gets created twice. Duplicate requests usually come from:
- HTTP clients and SDKs that retry on timeouts or 5xx errors.
- Users who double click "Pay" or refresh the page after submitting a form. Disabling the button helps, as in my post about forms with React Hook Form, but it doesn't protect the server.
- Load balancers, queues and webhooks that deliver the same message more than once.
The HTTP spec says this directly: a client should not automatically retry a request with a non-idempotent method unless it has some way to know the request is actually idempotent, and a proxy must never do it.
Idempotent HTTP methods
If you need a refresher on methods and status codes, see my HTTP primer for frontend developers. RFC 9110, the HTTP semantics spec, defines which methods are safe (read-only) and which are idempotent:
| Method | Safe | Idempotent |
|---|---|---|
GET | Yes | Yes |
HEAD | Yes | Yes |
OPTIONS | Yes | Yes |
TRACE | Yes | Yes |
PUT | No | Yes |
DELETE | No | Yes |
POST | No | No |
PATCH | No | No |
CONNECT | No | No |
Every safe method is idempotent: if a request doesn't change anything, repeating it doesn't either. The opposite isn't true. PUT and DELETE change state, but repeating them leads to the same state.
Keep in mind that this table describes what each method is supposed to do. Nothing stops a server from doing something non-idempotent in a PUT handler. It's your job, as the API author, to keep the promise.
Is POST idempotent?
No. POST usually means "create something" or "run this action", and each call can create a new resource. Ten POST /orders requests can create ten orders. That's why POST is where idempotency keys are used most.
Is PUT idempotent?
Yes. PUT replaces the resource at a URL with the body you send. Sending PUT /users/42 with the same body ten times leaves user 42 in the same state as sending it once.
Is PATCH idempotent?
Not in general. RFC 5789, which defines PATCH, says it is "neither safe nor idempotent". It depends on the patch: { "email": "[email protected]" } sets a value and is idempotent in practice, while a patch like "add 1 to quantity" or "append this item to the list" isn't. RFC 5789 also says a PATCH can be sent in a way that makes it idempotent, for example as a conditional request with an If-Match header, so it fails if the resource changed in the meantime.
Is DELETE idempotent?
Yes, even though the response changes. The first DELETE /orders/7 removes the order and may return 200 or 204. The next ones return 404, because the order is gone. The state on the server is the same after one call or ten: no order 7. MDN's glossary uses this exact example.
Make operations naturally idempotent
Before reaching for idempotency keys, check whether you can design the operation so that repeating it is harmless. A few ways:
- Set values instead of incrementing them:
status = 'paid'can run twice,paidCount += 1can't. - Let the client choose the ID. Instead of
POST /ordersgenerating a new ID each time, the client generates one and sendsPUT /orders/<id>, so a repeated request writes the same order. - Add unique constraints. With a unique index on
(user_id, external_id), a duplicate insert fails and no second row is created. - Check the current state first. "Mark invoice 123 as paid" can do nothing when the invoice is already paid.
When none of that fits, typically a POST that calls something outside your control (a payment provider, an email service), you need an idempotency key.
What is an idempotency key?
An idempotency key is a unique value that the client generates for one operation and sends with the request, usually in an Idempotency-Key header. If the request has to be retried, the client sends the same key. The server keeps a record of the keys it has seen, with the response it returned. When a key comes back, it returns the saved response instead of doing the work again.

POST /payments HTTP/1.1
Content-Type: application/json
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
{"amount": 1500}
You'll see this a lot in payment APIs. Stripe's API, for example, accepts an Idempotency-Key header on every POST in its v1 API. It suggests V4 UUIDs as keys (up to 255 characters), compares the parameters of a retry with the original request and returns an error if they differ, and may remove keys once they're at least 24 hours old.
The Idempotency-Key header draft
The IETF HTTPAPI working group wrote a spec for this header: draft-ietf-httpapi-idempotency-key-header. Its latest version (07, October 2025) expired in April 2026 without becoming an RFC, so there is no published standard for the header, only a convention that many APIs follow. The draft is still a good design guide. It recommends a UUID or similar random value as the key, and these responses from the server:
| Situation | Response |
|---|---|
| First request with a new key | Process it normally |
| Retry after the first request finished | The saved result (success or error) |
| Retry while the first request is still running | 409 Conflict |
| Key missing on an endpoint that requires it | 400 Bad Request |
| Same key with a different request body | 422 Unprocessable Content |
To detect "same key, different body", the server stores a fingerprint of the request (a hash of the body, for example) next to the key. Let's build exactly that.
How to implement idempotency keys in Node.js
Let's see in practice. First, an API without any protection, so we can watch the problem happen. Create a project and install Express:
npm init -y
npm pkg set type=module
npm i express
server.js has a payment endpoint that takes 2 seconds, like a slow payment provider:
import express from 'express'
import { randomUUID } from 'node:crypto'
const app = express()
app.use(express.json())
const payments = []
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
async function createPayment(amount) {
await sleep(2000) // a slow payment provider
const payment = { id: randomUUID(), amount, status: 'succeeded' }
payments.push(payment)
return payment
}
// no protection: every retry charges again
app.post('/unsafe/payments', async (req, res) => {
const payment = await createPayment(req.body.amount)
res.status(201).json(payment)
})
app.get('/payments', (req, res) => {
res.json(payments)
})
app.listen(3000, () => {
console.log('Listening on http://localhost:3000')
})
A client that retries
client.js gives up on a request after 1 second and tries again, up to 3 times. It generates the key once per operation and sends the same key on every attempt:
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
async function pay(url, amount) {
const key = crypto.randomUUID() // one key per operation, reused on every retry
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': key,
},
body: JSON.stringify({ amount }),
signal: AbortSignal.timeout(1000),
})
if (res.status === 409 || res.status >= 500) {
throw new Error(`HTTP ${res.status}`)
}
console.log(`Attempt ${attempt}: HTTP ${res.status}`, await res.json())
return
} catch (err) {
console.log(`Attempt ${attempt} failed: ${err.message}`)
if (attempt < 3) await sleep(500 * attempt)
}
}
}
await pay(process.argv[2], 4999)
crypto.randomUUID(), fetch and AbortSignal.timeout() are all built into Node.js, no packages needed. The wait between attempts grows (500 ms, then 1 s), a simple form of backoff.
Start the server with node server.js and, in another terminal, call the unsafe endpoint:
$ node client.js http://localhost:3000/unsafe/payments
Attempt 1 failed: The operation was aborted due to timeout
Attempt 2 failed: The operation was aborted due to timeout
Attempt 3 failed: The operation was aborted due to timeout
From the client's point of view, every attempt failed. Now look at the server:
$ curl http://localhost:3000/payments
[{"id":"4087d9c1-...","amount":4999,"status":"succeeded"},{"id":"b175dadf-...","amount":4999,"status":"succeeded"},{"id":"ef637f6e-...","amount":4999,"status":"succeeded"}]
There are three payments: the customer was charged three times and was told the payment failed. The client did send an Idempotency-Key, but this route ignores it. The header only helps when the server uses it.
The idempotency wrapper
Create idempotency.js. It has two parts: a store that remembers keys, and an idempotent() function that wraps a route handler:
import { createHash } from 'node:crypto'
const TTL_MS = 24 * 60 * 60 * 1000 // keep keys for 24 hours
const entries = new Map()
export const memoryStore = {
async start(key, fingerprint) {
const existing = entries.get(key)
if (existing) return existing
entries.set(key, { state: 'processing', fingerprint })
return null
},
async complete(key, entry) {
entries.set(key, entry)
setTimeout(() => entries.delete(key), TTL_MS).unref()
},
async remove(key) {
entries.delete(key)
},
}
export function idempotent(store, handler) {
return async (req, res) => {
const key = req.get('Idempotency-Key')
if (!key) {
return res.status(400).json({ error: 'Missing Idempotency-Key header' })
}
// in a real app, add the user or account ID to the key too
const storeKey = `idem:${req.method}:${req.path}:${key}`
const fingerprint = createHash('sha256')
.update(JSON.stringify(req.body ?? null))
.digest('hex')
const existing = await store.start(storeKey, fingerprint)
if (existing) {
if (existing.fingerprint !== fingerprint) {
return res
.status(422)
.json({ error: 'Idempotency-Key reused with a different body' })
}
if (existing.state === 'processing') {
return res
.status(409)
.json({ error: 'A request with this key is in progress' })
}
res.set('Idempotent-Replayed', 'true')
return res.status(existing.status).json(existing.body)
}
try {
const { status, body } = await handler(req)
await store.complete(storeKey, { state: 'done', fingerprint, status, body })
res.status(status).json(body)
} catch (err) {
await store.remove(storeKey) // let the client retry
throw err
}
}
}
Step by step, for each request:
- No key:
400. - It builds the store key from the method, the path and the client's key, and a SHA-256 fingerprint of the body.
store.start()either claims the key (marking it asprocessing) and returnsnull, or returns what's already saved under it.- If the key exists with a different fingerprint:
422. If it's stillprocessing:409, so the client tries again later. If it'sdone: the saved status and body, plus anIdempotent-Replayedheader so the client can tell it was a replay. - Otherwise, it runs the handler, saves the result and sends it. If the handler throws, it deletes the key so a retry can run the operation again, and passes the error on to Express (Express 5 handles errors thrown in async handlers).
The in-memory store works because start() reads and writes the Map without an await in between, and JavaScript runs that code without interruption. Two requests can't both claim the same key.
Important! Scope the key to whoever sent it. Keys come from clients, and two different users could send the same value. In a real API, put the user or account ID in storeKey.
Now add an idempotent version of the route to server.js. The handler returns { status, body } instead of calling res.json() itself, so the wrapper can save the result:
import { idempotent, memoryStore } from './idempotency.js'
// ...
app.post(
'/payments',
idempotent(memoryStore, async (req) => {
const payment = await createPayment(req.body.amount)
return { status: 201, body: payment }
}),
)
Let's test it?
Restart the server and run the same client against the new route:
$ node client.js http://localhost:3000/payments
Attempt 1 failed: The operation was aborted due to timeout
Attempt 2 failed: HTTP 409
Attempt 3: HTTP 201 {
id: '732bc320-8882-462a-92a8-5f87ca1e96f1',
amount: 4999,
status: 'succeeded'
}
Attempt 1 timed out, but the server kept working on it. Attempt 2 arrived while the payment was still running and got 409. Attempt 3 arrived after it finished and got the saved response. GET /payments shows a single payment.
You can test the other cases with curl. The same key twice returns the same payment, the second time with the replay header:
$ curl -i -X POST http://localhost:3000/payments \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324" \
-d '{"amount":1500}'
HTTP/1.1 201 Created
...
{"id":"fc1eefd0-257b-488b-bced-f8fa41fb4bba","amount":1500,"status":"succeeded"}
$ # same command again
HTTP/1.1 201 Created
Idempotent-Replayed: true
...
{"id":"fc1eefd0-257b-488b-bced-f8fa41fb4bba","amount":1500,"status":"succeeded"}
The same key with {"amount":9900} returns 422 Unprocessable Entity, and a request without the header returns 400.
Tip: On Windows, run these curl commands in Git Bash. The \ line breaks and the single quotes around the JSON body are Bash syntax, and PowerShell and cmd.exe read them differently.
Store idempotency keys in Redis
The Map works for one process, but it's lost on restart, and with two or more instances of the API behind a load balancer, a retry can land on an instance that has never seen the key. In production, keep the keys in a shared store. Redis is a common choice because of two features of the SET command: NX (only set if the key doesn't exist), which claims a key atomically, and EX, which makes it expire on its own.
npm i redis
redis-store.js has the same three methods as memoryStore:
import { createClient } from 'redis'
const LOCK_SECONDS = 60 // max time a request can stay "processing"
const TTL_SECONDS = 24 * 60 * 60
const redis = await createClient({ url: process.env.REDIS_URL }).connect()
export const redisStore = {
async start(key, fingerprint) {
const acquired = await redis.set(
key,
JSON.stringify({ state: 'processing', fingerprint }),
{ condition: 'NX', expiration: { type: 'EX', value: LOCK_SECONDS } },
)
if (acquired) return null
const saved = await redis.get(key)
// the lock may have expired between the two calls: ask the client to retry
return saved ? JSON.parse(saved) : { state: 'processing', fingerprint }
},
async complete(key, entry) {
await redis.set(key, JSON.stringify(entry), {
expiration: { type: 'EX', value: TTL_SECONDS },
})
},
async remove(key) {
await redis.del(key)
},
}
SET ... NX returns null when the key already exists, so only one request can claim it, even with several API instances. The processing entry expires after 60 seconds: if the server crashes in the middle of a request, the key frees itself and the client can retry. Pick a value longer than your slowest request, or a retry may run while the first attempt is still going. Once the request finishes, complete() replaces the entry with the result and a 24-hour expiration.
Note: In version 6 of the redis package, the condition and expiration options replace the older { NX: true, EX: 60 } form, which still works but is marked as deprecated.
In server.js, only the store changes:
import { idempotent } from './idempotency.js'
import { redisStore } from './redis-store.js'
// ...
app.post(
'/payments',
idempotent(redisStore, async (req) => {
const payment = await createPayment(req.body.amount)
return { status: 201, body: payment }
}),
)
To try it locally, run Redis with Docker and point REDIS_URL to it:
docker run -d --rm --name redis -p 6379:6379 redis:8
REDIS_URL=redis://localhost:6379 node server.js
I sent two requests with the same key at the same time: one got 201 and the other 409. And the key in Redis has the 24-hour expiration:
$ docker exec redis redis-cli TTL "idem:POST:/payments:abc-123"
(integer) 86399
If you prefer a database, the same idea works with a table that has a unique constraint on the key: the INSERT fails for a key that already exists, and you save the response in the same row.
Pass the key downstream
Your API is often not the last step. If the payment handler calls a payment provider, a crash after the charge but before complete() leaves the key in processing. After it expires, the retry runs the handler again and charges again.
To close that gap, reuse the key when you call a provider that supports idempotency. With the Stripe Node.js SDK, it's a request option:
const paymentIntent = await stripe.paymentIntents.create(
{ amount: 4999, currency: 'usd' },
{ idempotencyKey: req.get('Idempotency-Key') },
)
Now if your handler runs twice for the same key, Stripe returns the first result the second time. The SDK already adds a random key to every POST so that its own automatic retries are safe, but that key is new on every call. It doesn't help when your code calls it again after a crash; your own key does.
Idempotency for webhooks and queue consumers
The same problem shows up on the receiving side. Webhook providers and message queues retry until you acknowledge a message, so you will eventually get the same one twice. Stripe's webhook docs say it plainly: an endpoint may receive the same event more than once, so log the event IDs you've processed and skip the ones you've already seen.
That's an idempotent consumer: the message ID plays the role of the idempotency key. The stores above work for that too: call store.start() with the event ID, and skip the event when it returns an entry.
FAQ
What's the difference between safe and idempotent?
A safe method doesn't change state on the server (GET, HEAD, OPTIONS). An idempotent method can change state, but repeating it doesn't change it any further (PUT, DELETE). Every safe method is idempotent, but PUT and DELETE are idempotent without being safe.
Should GET requests use idempotency keys?
No. GET is already idempotent, so there's nothing to protect. Stripe, for example, says sending a key on GET and DELETE has no effect. Keys are for POST, and for PATCH when the change isn't idempotent.
How long should I keep idempotency keys?
Long enough to cover every retry a client could make. 24 hours is a common choice, and it's what Stripe uses as its minimum. Document whatever you choose, so clients know when a key can be reused.
Is idempotency the same as deduplication?
They're related. Deduplication drops repeated messages; idempotency makes repeating them harmless. Idempotency keys deduplicate requests, and the client also gets the original response back instead of an error.
Conclusion
Idempotency means that doing something twice has the same effect as doing it once. GET, PUT and DELETE have it by definition. For POST and non-idempotent PATCH requests, an idempotency key lets the client retry without fear: the server claims the key, runs the operation once and returns the saved response to every retry. In Node.js, that fits in a small wrapper and a Redis store with three methods. If you want to write it in TypeScript, see Node.js with TypeScript.
See you next time!
Links
- RFC 9110: Idempotent Methods
- RFC 5789: PATCH Method for HTTP
- MDN: Idempotent
- The Idempotency-Key HTTP Header Field (IETF draft)
- Stripe: Idempotent requests
- Stripe: Webhooks
- Redis SET command
- Idempotence on Wikipedia
Tags: nodejs|http|javascript