The client sends a POST request to /orders. The connection drops right after the server processes the order, but before the response makes it back. The client retries the operation. The server receives a second, identical request and creates a second order, duplicating the data. The client gets a successful response, the user gets billed twice, and nobody notices until the support ticket arrives (or worse, until the company's own charge shows up).
This isn't a bug in the "traditional" sense. The code did exactly what it was written to do. The problem is that it did it twice. HTTP offers no guarantee that a request arrives exactly once, and if the API assumes it does, there's a mismatch between the two contracts.
1. The assumption
When an HTTP request fails, the client can't distinguish between three different scenarios:
- The request never reached the server.
- The request reached the server, but it failed before it could be processed.
- The request reached the server and was processed, but the response got lost along the way (a timeout, a dropped connection, a process restarting...).
All three look the same "silence" from the user's side.
The natural reaction is to retry. Assume the operation failed. That assumption can be wrong. Only one of those three scenarios truly corresponds to "nothing happened." Retrying in the other two means repeating the operation against data that has already changed.
GET is idempotent by definition: reading content twice doesn't change it. But POST can't be idempotent by design. Most operations that actually matter are POSTs.
Any operation behind a POST, a PUT (not idempotent by nature), or a PATCH can produce these scenarios. The slower the operation, the wider the window in which a timeout can occur.
2. Idempotency keys. What they are
An idempotency key is a unique identifier (an ID) that the client generates for a specific operation. That key gets sent with the request, typically in a header, and reused on every retry of that same operation. When the server receives a request with a key it has already processed, it doesn't re-run the operation; it returns the response it generated the first time.
The key doesn't describe what the request does; it only identifies that attempt uniquely. Two requests with the same key count as the same operation, even if the body is different (although checking that content can be an interesting guard to implement).
1// The client generates the key once, before the first attempt,
2// and reuses it on every retry of that same operation.
3import { randomUUID } from 'crypto'
4
5async function createOrder(payload: OrderPayload) {
6 const idempotencyKey = randomUUID()
7
8 return retryWithBackoff(() =>
9 fetch('/orders', {
10 method: 'POST',
11 headers: {
12 'Content-Type': 'application/json',
13 'Idempotency-Key': idempotencyKey,
14 },
15 body: JSON.stringify(payload),
16 })
17 )
18}The critical point is that the key isn't generated by the server; the client is responsible for it. Otherwise the server would have no way to recognize an attempt, since every request would arrive with a different key. The whole point of the key is to let the client say "this is the same attempt as before," even without knowing whether that previous attempt succeeded.
3. Where and how to store the result
To use the key, the server needs somewhere to remember that key and the response tied to it. At minimum, a table or collection with the key as a unique identifier, the operation's status, and the result.
1type IdempotencyRecord = {
2 key: string
3 requestHash: string // hash of the body, to detect improper reuse
4 status: 'processing' | 'completed' | 'failed'
5 responseStatus?: number
6 responseBody?: unknown
7 createdAt: Date
8 expiresAt: Date // TTL: this isn't kept forever
9}
10
11interface IdempotencyStore {
12 find(key: string): Promise<IdempotencyRecord | null>
13 create(record: IdempotencyRecord): Promise<void>
14 complete(key: string, status: number, body: unknown): Promise<void>
15}The requestHash makes sure that a buggy client reusing the same key for two different operations doesn't silently get back the original order's response with no error pointing out that something doesn't match. To do that, the body's hash gets compared against the one stored the first time. With that in place, an error like 409 Conflict can be raised.
Keeping idempotency keys forever is a memory leak shaped like a table. A 24-hour TTL is usually enough.
4. Why a plain SELECT isn't enough
The most common mistake when implementing this for the first time is checking whether the key exists, and if it doesn't, proceeding to create the resource. But reading and then writing can open a race window: with two requests carrying the same key arriving almost at the same time, both can run their SELECT before either finishes its INSERT, so both end up executing the operation.
To avoid that, creating the idempotency record has to be atomic: the insert is attempted first, and only if that insert succeeds does the business operation run. If it fails (because the key already exists), the request looks up the stored result instead of taking any further action.
1async function handleIdempotentRequest(
2 key: string,
3 requestBody: unknown,
4 store: IdempotencyStore,
5 operation: () => Promise<{ status: number; body: unknown }>
6): Promise<{ status: number; body: unknown }> {
7 const requestHash = hashBody(requestBody)
8
9 const existing = await store.find(key)
10
11 if (existing) {
12 if (existing.requestHash !== requestHash) {
13 return { status: 409, body: { error: 'Idempotency key reused with a different payload' } }
14 }
15 if (existing.status === 'completed') {
16 return { status: existing.responseStatus!, body: existing.responseBody }
17 }
18 if (existing.status === 'processing') {
19 return { status: 409, body: { error: 'Request with this key is still processing' } }
20 }
21 }
22
23 // create() must fail (a unique key constraint in the DB) if another
24 // concurrent request already inserted the same key between the find() and here.
25 try {
26 await store.create({
27 key,
28 requestHash,
29 status: 'processing',
30 createdAt: new Date(),
31 expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
32 })
33 } catch {
34 // We lost the race: another concurrent request is already processing this key.
35 return { status: 409, body: { error: 'Request with this key is already being processed' } }
36 }
37
38 const result = await operation()
39 await store.complete(key, result.status, result.body)
40 return result
41}The real guarantee doesn't come from the application code; it comes from the UNIQUE constraint on the key column in the database. The initial find() is just an optimization for when the key has already been resolved. The actual protection against the race lives in the fact that the second concurrent INSERT fails at the database level, not in any in-memory check that runs before it.
If idempotency is a cache living in the process's own memory, that guarantee breaks down as soon as more than one instance is running. Two requests with the same key can land on two different instances, each unaware of the other, and end up being valid within its own context. The store has to be shared across every instance.
5. Operations that are already idempotent
Not every operation needs this mechanism. A PUT /users/42 that fully replaces the resource with a fixed payload is already idempotent by definition, so running it once or five times leaves the resource in the same final state. Adding idempotency keys there is extra work with no real benefit.
It's operations that create something new every time they run, or that accumulate an effect with every call (creating an order, processing a charge, sending a notification...) where idempotency needs to be added. If running an operation twice with the same input produces the same result, this mechanism isn't necessary. Otherwise, it has to be included.
HTTP doesn't have idempotency as a built-in feature. HTTP provides a convention to communicate it, but it's the code that decides what to do with the state.
6. A generic middleware in Express
The right way to apply the mechanism from section 4 is to build a middleware that wraps any route that needs it. That way, the business handler stays completely free of that concern.
1import type { Request, Response, NextFunction } from 'express'
2
3function idempotent(store: IdempotencyStore) {
4 return async (req: Request, res: Response, next: NextFunction) => {
5 const key = req.header('Idempotency-Key')
6
7 if (!key) {
8 return res.status(400).json({ error: 'Idempotency-Key header is required' })
9 }
10
11 const requestHash = hashBody(req.body)
12 const existing = await store.find(key)
13
14 if (existing) {
15 if (existing.requestHash !== requestHash) {
16 return res.status(409).json({ error: 'Idempotency key reused with a different payload' })
17 }
18 if (existing.status === 'completed') {
19 return res.status(existing.responseStatus!).json(existing.responseBody)
20 }
21 return res.status(409).json({ error: 'Request with this key is already being processed' })
22 }
23
24 try {
25 await store.create({
26 key,
27 requestHash,
28 status: 'processing',
29 createdAt: new Date(),
30 expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
31 })
32 } catch {
33 return res.status(409).json({ error: 'Request with this key is already being processed' })
34 }
35
36 // res.json is intercepted to capture the handler's response
37 // and store it against the key, without the handler having to know about it.
38 const originalJson = res.json.bind(res)
39 res.json = (body: unknown) => {
40 store.complete(key, res.statusCode, body)
41 return originalJson(body)
42 }
43
44 next()
45 }
46}
47
48// usage
49app.post('/orders', idempotent(idempotencyStore), async (req, res) => {
50 const order = await createOrder(req.body)
51 res.status(201).json(order)
52})This middleware doesn't need to know anything about orders, charges, or any other domain concept. It only knows the technical part (the key, the body hash, how to intercept the response). That means it can be reused on any endpoint that needs it by adding a single line, and its tests stay independent from each individual handler.
7. A few common mistakes when implementing this
- Using a
requestIdortraceIdas an idempotency key. They're different things: atraceIdis usually generated anew on every attempt to distinguish log lines, while the idempotency key has to stay exactly the same across every retry of the same logical operation. Mixing them up makes every "retry" look like a brand new operation. - Storing only the response's
statusand not the fullbody. The client expects the exact same response it would have received the first time. Without the stored body, there's no way to reconstruct that response. - Not handling the
processingstate explicitly. If a long-running request is still in flight and a retry arrives, the correct response is a409telling the client to wait and retry later, with the operation's key untouched.
Applying the idempotency key after the operation has already triggered an irreversible external effect, but before the result is stored, can cause very serious bugs. If the process dies right there, the idempotency record never reaches completed, and the retry won't know the charge already went through.
8. Conclusions
Idempotency isn't some "exotic" feature reserved for payment gateways. It's the correct answer to a basic fact about networks: a request can fail at three different points, and the client can't tell which one happened just by looking from the outside. Retrying without an idempotency mechanism assumes, with no real basis, that every failure is of the "nothing happened" kind.
- Separate HTTP idempotency (a header convention) from real idempotency (a property of how the code treats state).
- The key is generated and reused by the client, never by the server.
- Store the body's hash alongside the key to detect improper reuse with a different payload.
- Protection against race conditions lives in a unique constraint at the database level, not in an in-memory
SELECTfollowed by anINSERT. - An idempotency store local to a single process doesn't work behind a load balancer with more than one instance.
- Store the full result (status and body), not just a success flag, and give it a reasonable TTL.
Not every operation needs this. The ones that do (the ones that create or accumulate something every time they run) are exactly the ones that hurt the most when they go wrong twice instead of once.
