Idempotency & exactly-once (the lie)

At-least-once delivery is what you actually get - designing handlers that survive being called twice.

20 min read

Exactly-once delivery does not exist

The moment you added retries, you created a new problem. A worker charges a card, then crashes before it can mark the job done. The queue, quite correctly, has no idea the work happened - so it hands the job to another worker. The customer is charged twice.

The instinct is to want 'exactly-once delivery'. It is not available. Any system that can lose a message can retry it, and no acknowledgement can be both sent and guaranteed to arrive. You get to choose between at-most-once (never duplicated, sometimes lost) and at-least-once (never lost, sometimes duplicated).

Nobody chooses to silently lose payments. So you take at-least-once and make duplicates harmless. That property - doing it twice has the same effect as doing it once - is idempotency, and it is the single most important word in this module.

Imagine it like this: A lift button. Pressing it five times doesn't summon five lifts. The second press changes nothing - the button is idempotent.

Words you just learned
at-least-once
- the message is never lost, but may arrive more than once
at-most-once
- never duplicated, but may be lost entirely
idempotent
- doing it again has no additional effect

The idempotency key

The mechanism is simple. The caller invents a unique id for its intent - not for the attempt, for the intent - and sends it with every retry of that same request. The server records which keys it has already completed, and if it sees one twice it returns the original result instead of doing the work again.

The subtlety that catches people: the check and the work must be one atomic step. If you check 'have I seen this key?' and then insert afterwards, two retries arriving together can both pass the check before either inserts. The database's own uniqueness guarantee is what makes this safe - a UNIQUE constraint on the key, and let the second one lose.

idempotent_charge.sql
sql
CREATE TABLE charges (
  idempotency_key TEXT PRIMARY KEY,   -- the DB enforces "only once"
  user_id         BIGINT NOT NULL,
  amount_cents    INT    NOT NULL,
  created_at      TIMESTAMPTZ DEFAULT now()
);

-- Every retry sends the SAME key. The second one changes nothing.
INSERT INTO charges (idempotency_key, user_id, amount_cents)
VALUES ('order-8821-charge', 7, 4999)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id;
What it prints
first attempt   INSERT 0 1     id = 3310   -> charge the card
retry (crash)   INSERT 0 0     (no rows)   -> already done, return 3310
The retry returns zero rows. That empty result is the signal: someone already did this. The uniqueness check and the write are a single atomic statement, so two simultaneous retries cannot both win.
Key the intent, not the attempt

If the client generates a fresh id on every retry, you have built nothing - each attempt looks like a brand-new request. The key must be created once, when the user clicks the button, and reused by every retry of that click. This is exactly how Stripe's Idempotency-Key header works.

Words you just learned
idempotency key
- a caller-supplied id identifying one intent across all its retries
atomic
- happens completely or not at all, with nothing able to interleave

Some operations are already safe

Not everything needs a key. Look at the shape of the operation first, because some are naturally idempotent and some can be rewritten to be.

SET status = 'shipped' is idempotent - run it a thousand times, the status is shipped. balance = balance - 10 is not - each run takes another ten. That difference is the whole lesson: prefer assignment over adjustment, and absolute values over relative ones.

When you genuinely need an increment, the key gives you back the safety the operation itself lacks. Record that this key was applied, and refuse to apply it a second time.

shapes.sql
sql
-- Idempotent: the outcome is the same however many times it runs
UPDATE orders SET status = 'shipped'  WHERE id = 8821;
DELETE FROM carts WHERE user_id = 7;

-- NOT idempotent: every run changes the answer again
UPDATE accounts SET balance = balance - 1000 WHERE id = 7;
INSERT INTO emails (user_id, kind) VALUES (7, 'welcome');
What it prints
run 3x:  status = 'shipped'        (same)
run 3x:  cart deleted             (same)
run 3x:  balance -3000            (DIFFERENT - charged 3 times)
run 3x:  3 welcome emails sent    (DIFFERENT - user is annoyed)
Two of these are safe to retry by their shape alone. The other two need an idempotency key before they touch a queue. Sort your handlers into these two piles and you'll know exactly where the risk is.
Remember these
  • Exactly-once is not available; at-least-once plus idempotency is
  • Prefer setting absolute values over adjusting relative ones
  • Key the user's intent, and reuse that key across every retry
  • Enforce uniqueness in the database, not in application logic
Words you just learned
natural idempotency
- operations that are safe to repeat because of what they do
deduplication
- recognising and discarding work that has already been done