Part III · Chapter 5 · Cache and Queue · lesson 6
Your queue says "at least once", which means twice
14 min
Builds on The payment went through twice
Part III · Chapter 5 · Cache and Queue · lesson 6
14 min
Builds on The payment went through twice
Some customers got the receipt twice. Not many — the ones whose consumer was redeployed between sending the email and acknowledging the message. The queue's documentation says "at least once", and it was telling the truth. It reads like a floor on reliability, which is why nobody read it as what it also is: a statement that some messages will be delivered more than once, by design, forever.
Predict
What each guarantee actually promises.
At most once. Acknowledge first, then work. A crash loses the message silently. Correct for a metric sample or a presence update, where the loss is statistical. Almost never what a business process wants.
At least once. Work first, then acknowledge. A crash between the two redelivers. Duplicates are not a broker defect; they are the mechanism by which it keeps its promise. The default nearly everywhere.
Exactly once. Available as an atomic read-process-write within one system — Kafka's transactions, or a database holding both the queue and the effect. Not available across systems: committing an email and a message together needs a protocol the email provider does not implement.
So "exactly once" in practice means: at-least-once delivery, plus a consumer whose repeated application leaves the same state. The guarantee you want is about your code, not about the queue.
Natural idempotence — assignment, not accumulation. SET status = 'shipped' applied twice is one outcome. UPDATE counter = counter + 1 applied
twice is a bug. A surprising share of handlers can be rewritten from the second
shape to the first, and when they can, everything else on this list becomes
unnecessary. Carry the final value in the message rather than the delta.
A unique constraint in the same transaction as the effect. Insert the
message's identifier into a processed_messages table with a primary key, in
the same transaction that writes the effect, and let the second attempt fail the
insert. INSERT ... ON CONFLICT DO NOTHING and check whether you inserted. This
is the only mechanism on this list that is actually correct, and the reason is
atomicity: the record of having processed the message and the effect of
processing it commit or roll back together, so there is no window where one
exists without the other.
Conditional writes. UPDATE ... WHERE version = 4, or a DynamoDB
ConditionExpression that fails if the item already reflects the change. The
same atomicity property, expressed in the data instead of in a side table, and
it needs no extra storage. It works where the effect has a natural version and
not where it does not.
A duplicate-suppression store with a window. Redis SET dedupe:id 1 NX EX 300 before doing the work: if the key already existed, skip. This is the one
people reach for, and it is the weakest of the four, for two reasons. The store
is not in the same transaction as the effect, so a crash between the key and the
work loses the message entirely — you have marked as done something that never
happened. And it only suppresses duplicates that arrive while the key is still
there, which makes correctness a function of a retention window you guessed at.
That last point is arithmetic, so here it is.
Duplicate suppression window
Hypothetical model, not a forecast
How many keys a duplicate-suppression store holds, what it costs in memory, and how many duplicates slip through because they arrived after the key expired.
It assumes
It ignores
At a five-minute window, about 1,642 duplicates per million requests get through. Find the retention window that brings that under 100 per million, then read what the store costs there.
Everything arriving each second, duplicates included.
How long one key is kept before it expires.
| Retention window | Duplicates that get through, per second |
|---|---|
| 1000 | 39.67/s |
| 5000 | 38.37/s |
| 10000 | 36.8/s |
| 30000 | 31.15/s |
| 60000 | 24.26/s |
| 300000 | 3.28/s |
| 600000 | 0.27/s |
Not there yet — keep moving the controls.
A little over ten and a half minutes, and the store roughly doubles: from 588,000 keys to about 1.25 million. That is the trade in one sentence — suppressing the last stragglers costs memory linearly while buying correctness logarithmically, and the window that catches 99% of duplicates here is about nine minutes, which is far longer than the five somebody picked because it sounded safe.
Then the uncomfortable part. Even at ten and a half minutes you are letting about one duplicate per ten thousand requests through, and the real distribution of redelivery delays is not the smooth curve the model assumes. It is two spikes: the immediate retry seconds later, and the replay hours or days later when somebody reprocesses a topic or drains a parked queue. No window catches the second spike. A unique constraint catches it for the lifetime of the table.
Kafka transactions. Since the 0.11 release, a producer can write records and
commit consumer offsets in one transaction, and a consumer reading with
isolation.level=read_committed never sees records from a transaction that was
aborted. Kafka Streams wraps this as processing.guarantee=exactly_once_v2. The
scope is Kafka to Kafka: records in, records out, offsets moved, all or nothing.
It does not and cannot cover a write to Postgres, a charge at a payment
provider, or an email. It also costs throughput and adds a transaction
coordinator to your failure modes, which is why it is off by default.
A queue inside the database. If the messages are rows in Postgres and the effect is also in Postgres, claim the job and apply the effect in one transaction and you have exactly-once for real, with no extra machinery. This is the quiet advantage of a queue table over a broker, and it stops being available the moment the effect leaves the database.
SQS FIFO deduplication. A deduplication id suppresses repeat sends inside a fixed window — five minutes, as of this writing, not configurable. Useful, and note what it is: the model above, implemented by somebody else, with the window chosen for you. Same shape, same limitation.
The transactional outbox, for the producing side. Write the business row and
a row in an outbox table in one transaction; a relay reads the outbox and
publishes. That makes "the event exists if and only if the change happened"
true, which removes the producer's half of the problem. The consumer still has
to be safe to repeat, because the relay can publish twice.
A unique constraint on a business key, in the same transaction as the effect. Not a dedup store — a constraint, in the database that holds the thing being changed, on an identifier the producer supplies and does not reuse.
If the effect is at a third party, use their idempotency mechanism and store the key: a payment provider's idempotency header makes the remote side safe to repeat, and recording the key locally in the same transaction as your own row makes the pair safe. If the third party has no such mechanism, you cannot have exactly-once against it, and the honest design is to make the duplicate harmless, visible, or cheap — in that order of preference.
The Redis dedup store I would use for one thing only: cost. If a duplicate triggers an expensive computation, a short window in front of it saves most of the expense most of the time, and the correctness still lives in the constraint underneath. Dedup store as an optimisation, constraint as the guarantee. Using the store as the guarantee is the specific mistake this lesson exists to stop.
Check
All of this machinery — the window, the store, the memory, the arithmetic — is the price of a consumer that is not naturally idempotent. Make it naturally idempotent and the entire lesson becomes unnecessary.
Three questions tell you whether that is available. Does the message carry the final value rather than a change to apply? Can the effect be expressed as setting a field rather than incrementing one? Is there an identifier in the data that could carry a unique constraint without inventing a side table?
Where the answer to all three is yes, applying the message twice costs a wasted query and nothing else. No dedup store, no window to tune, no memory bill that grows with traffic, and — the part that matters most — no new failure mode at the boundary between two systems that cannot commit together. The replay from six months ago is safe. Reprocessing the whole topic to fix a bug is safe, which is the capability that makes a log worth keeping in the first place.
So the first move on any duplicate problem is not to reach for suppression. It is to ask whether the handler can be rewritten to not care, and to spend the afternoon finding out. Suppression is what you build when the answer is no.