Skip to content

Idempotency

2 min read · main part only; folded sections are optional

After this you can answer: What happens when a user double-clicks Book or the network sends the request twice?

sequenceDiagram
participant B as Browser
participant A as API + Postgres
B->>A: book 2 seats, key K
A->>A: booked #b1, seats -2, COMMIT
A--xB: 200 #b1 (answer lost)
B->>A: retry: book 2 seats, key K
A->>A: key K already has #b1, write nothing
A-->>B: 200 #b1 (same answer)
  1. One key per intent. The booking form makes a UUID when the user decides to book. Every retry of that booking sends the same key; a changed quantity gets a new key.
  2. Look it up under the lock. Inside the transaction, right after locking the event row, the query looks for a booking with this user and key. Found: return it, write nothing.
  3. Same or different? The service compares event and quantity. Same: a replay, answer 200 with the first booking. Different: 409 “This idempotency key was already used for a different booking”.
  4. The constraint decides races. A UNIQUE constraint on (user, key) stops two copies that arrive at the same moment. The loser’s whole transaction, seats included, rolls back.

The key lives in the booking row itself, so it commits or rolls back together with the booking and the seats; it can never disagree with them (apps/api/src/queries/bookings.ts → createBookingTx).

The browser retries a booking at most twice (3 tries, after 1 s and 2 s), only on network errors and 5xx, never on a 4xx (apps/web/src/features/bookings/lib/booking-retry.ts → shouldRetryBooking). It is the only mutation that retries, and only because of the key.

Measured locally (pnpm demo:idempotency): 10 copies of one request gave 10 answers of 200, one booking id, one row, and only 2 seats taken.

Play the booking flow on the map →

Nothing to see in the console: the key is a column in Postgres. The API log has a debug line “replayed for a repeated idempotency key”.

SymptomCauseWhere to look
A user is charged twice for one clickThe client made a new key per retryThe form’s key handling
A retry of the last seats says “Not enough seats left”The key lookup ran after the seat checkThe order inside the transaction
409 “key was already used”The client reused a key for a different bookingMake a new key per intent
Why check the key before the seat check?

If the first copy took the last seats, the seat check would now say “not enough seats” for a booking the user already has. A repeat must get the first answer, whatever changed since.

Why not keep the keys in Redis with a TTL?

Redis and Postgres share no transaction. A crash between the two writes leaves them disagreeing, and an evicted key silently drops the protection. Keeping the key in the booking row avoids both.

Is the key scoped per user? Why?

Yes. Two users could, by chance, send the same key. Scoping by user means they never share a booking.

What is the trade-off of never expiring keys?

They cost about 16 bytes plus an index entry per booking, forever. Fine at our size. A payment API would store full responses for about 24 hours instead.

Advanced: Where the logic lives

The query only reports “created” or “existing”. Whether an existing booking is the same request is a business decision, so the service compares the event and the quantity. A failed booking stores nothing, so retrying it with the same key simply tries again: “not enough seats” may be wrong a second later.

Answer these without scrolling up. If one feels hard, reread that section.

  1. What does idempotent mean, in one sentence?
  2. When does the browser make a new key, and when does it reuse one?
  3. What does the API answer to the same key with a different quantity?
  4. What settles two copies that arrive at the very same moment?
  5. Why does only the booking mutation retry automatically?

Redraw it from memory: the sequence: book with key K, answer lost, retry with K, the same booking returned

Try this: run pnpm demo:idempotency on the local stack and check how many booking rows it made

Read this book online: https://book.theversehub.in/