Idempotency
The retry problem
Picture this. A user clicks “Place Order” on your bookstore. The client sends a POST request to create the order. The server processes it, inserts the order into the database, and sends back a response. But the network hiccups, and the response never arrives. The client doesn’t know if the order went through. So it retries.
What happens? A second order is created. The customer gets charged twice.
This is the retry problem, and it’s one of the most important things to understand when designing APIs. Networks are unreliable. Responses get lost. Clients retry. And the question is: does that retry cause damage?
The answer depends on whether the operation is idempotent.
What idempotent means
An operation is idempotent if calling it multiple times produces the same result as calling it once. The outcome is the same whether you run it 1 time or 100 times.
Let’s go through each HTTP method:
GET is idempotent. Reading a book 10 times gives you the same book each time (assuming nothing else changed it). No side effects.
PUT is idempotent. Replacing a book with the same data 10 times leaves the book in the same state. The first call sets it; the other 9 are effectively no-ops.
DELETE is idempotent. Deleting a book 10 times deletes it once. The remaining 9 calls find nothing to delete. The end state is the same: the book is gone.
POST is not idempotent. Creating a book 10 times creates 10 books. Each call produces a new side effect. This is where the retry problem lives.
Why this matters in practice
Think about it from the client’s perspective. If the client sends a PUT and the response is lost, it can safely retry. The same PUT runs again, sets the same data, and the result is identical. No harm done.
If the client sends a DELETE and the response is lost, it can safely retry. The book is already gone. The retry either deletes it again (same result) or finds nothing to delete.
But if the client sends a POST and the response is lost, retrying is dangerous. The server already created the resource. The retry creates a second one. Now you have a duplicate book, a duplicate order, or a duplicate payment.
This is exactly how production systems handle it, by the way. Payment APIs like Stripe and Square are extremely careful about this, because a duplicate charge is a real problem that costs real money.
Making POST safer with idempotency keys
So how do you make POST safe to retry? With an idempotency key.
The idea is simple. The client generates a unique ID (usually a UUID) and sends it along with the request in a header. The server checks: have I seen this key before? If yes, return the original response without doing anything. If no, process the request and store the key for later.
const idempotencyStore = new Map<string, any>();
route.post("/books", {
request: { body: CreateBookBody },
resolve: (c) => {
const idempotencyKey = c.request.headers.get("idempotency-key");
if (idempotencyKey) {
const cached = idempotencyStore.get(idempotencyKey);
if (cached) {
// We already processed this key. Return the original response.
return Response.json(cached, { status: 201 });
}
}
if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
const { title, isbn, genre, publishedAt, authorId } = c.input.body;
// Verify author exists
const author = authors.find((a) => a.id === authorId);
if (!author) return Response.json({ error: "Author not found" }, { status: 400 });
const book: Book = {
id: crypto.randomUUID(),
title,
isbn,
genre,
publishedAt,
authorId,
createdAt: new Date().toISOString(),
};
books.push(book);
// Store the idempotency key so retries return the same response
if (idempotencyKey) {
idempotencyStore.set(idempotencyKey, book);
}
return Response.json(book, { status: 201 });
},
}); This is the same POST handler from the previous lesson, with three additions. At the top, we read the Idempotency-Key header. If the key exists and we’ve seen it before, we return the cached response immediately without creating anything. At the bottom, after creating the book, we store the key and the response so future retries can find it.
Let’s walk through what happens step by step.
The client sends POST /books with an Idempotency-Key: abc-123 header. The server checks the idempotencyStore map: is there an entry with key abc-123? No, this is the first time. So the server validates the body, verifies the author, creates the book, stores the key, and returns 201.
Now the response gets lost. The client retries with the same Idempotency-Key: abc-123. The server checks the map again. This time, it finds a match. Instead of creating a second book, it returns the original response, the book that was already created.
The client gets back exactly what it would have gotten the first time. No duplicates. No double charges. The retry is completely safe.
Try it out
Let’s verify this actually works. First, send a POST with an idempotency key:
curl -X POST http://localhost:3000/books \
-H "Content-Type: application/json" \
-H "Idempotency-Key: test-key-1" \
-d '{"title":"The Sun Also Rises","genre":"fiction","authorId":"author-1"}' You’ll get back a 201 with the new book. Note the id in the response.
Now send the exact same request again, same key and everything:
curl -X POST http://localhost:3000/books \
-H "Content-Type: application/json" \
-H "Idempotency-Key: test-key-1" \
-d '{"title":"The Sun Also Rises","genre":"fiction","authorId":"author-1"}' You get back 201 again, with the same id as before. No second book was created. The server recognized the key and returned the cached response.
To prove it, list all books and count them:
curl http://localhost:3000/books There should be only one copy of “The Sun Also Rises.” If you send the same request a third time with the same key, still one copy. That’s idempotency in action.
Now try without a key to see the difference:
curl -X POST http://localhost:3000/books \
-H "Content-Type: application/json" \
-d '{"title":"The Sun Also Rises","genre":"fiction","authorId":"author-1"}'
curl -X POST http://localhost:3000/books \
-H "Content-Type: application/json" \
-d '{"title":"The Sun Also Rises","genre":"fiction","authorId":"author-1"}' Two requests, no key, two books created. That’s the problem idempotency keys solve.
[!NOTE] Stripe, Square, and many payment APIs require idempotency keys on POST requests to prevent duplicate charges. The key is typically a UUID generated by the client.
DELETE and 404
There’s a small design question with DELETE. If you delete a book and then try to delete it again, should the second call return 204 (success, the book is gone, which is what you wanted) or 404 (not found, there’s no book to delete)?
Both approaches are valid. The idempotent interpretation says: the desired end state is “the book doesn’t exist,” and that’s true, so return 204. The strict interpretation says: there was nothing to delete, so return 404.
Pick one and be consistent. This course uses 404 for attempts to delete something that doesn’t exist, because it gives the client more information about what happened. But the other approach is equally common.
What’s next
Idempotency is about whether retries are safe. But there’s another important property of HTTP methods: safety. A “safe” method is one that never modifies data. GET is safe. POST, PUT, PATCH, and DELETE are not. And violating this rule can have surprising consequences. We’ll look at that next.
Exercises
Exercise 1: Send the same PUT request twice. Verify the resource has the same state after both calls.
Exercise 2: Send the same POST request twice (without an idempotency key). Verify two resources are created.
Exercise 3: Implement the idempotency key pattern. Send the same POST request twice with the same key. Verify only one resource is created.
Why is POST not idempotent?