Home → Engineering
How Many Times Does a Message Arrive?
Tuesday: a fill notification arrived twice, a duplicate record was created, the balance is wrong. Thursday: a price tick was lost, the streaming engine kept the old price, and an order opened at a level that did not exist. Two different diseases; looking for one cure is a waste of time.
- There are three guarantees and each gives something up. Loss, duplication, or latency.
- Exactly-once delivery does not exist. What exists is at-least-once delivery plus a consumer that drops duplicates.
- Loss can be acceptable — for a price tick yes, for a fill never.
- Duplication is inevitable. Every consumer must be ready to see the same message twice.
Why you have to choose a guarantee
Start with a simple situation: you sent a message and no reply came. What happened?
- The message never arrived.
- The message arrived, was processed, and the acknowledgement was lost on the way back.
On the sending side these two are indistinguishable. Both look the same: silence. And you have exactly two decisions available:
- Send again → the message is never lost but may be processed twice. That is at-least-once.
- Do not send → it is never processed twice but may be lost. That is at-most-once.
There is no third option. The whole subject comes out of that one ambiguity.
| Guarantee | Promises | Costs | Where in trading |
|---|---|---|---|
| At-most-once | Never arrives twice | May be lost | Price ticks (UDP multicast), metrics, heat maps |
| At-least-once | Never lost | May arrive twice | Orders, fills, position changes, balance movements |
| Exactly-once | Processed exactly once | Extra machinery + latency | What you build on top of the above, in the consumer |
At-most-once: the lost tick problem
Price feeds usually arrive over UDP, and UDP promises nothing: if a packet drops it is gone and nobody resends it. The first time you hear this it sounds wrong — why not use TCP?
Because a repeated tick is useless. Getting the EURUSD price from 50 ms ago has no value; a newer one already arrived. The time TCP would spend retransmitting does not recover data, it delays the whole stream. Here loss is acceptable; latency is not.
A tick was lost and the streaming engine kept working with the last price it had. The market moved, your price stood still, and an order opened at that old price. That is a stale quote, and it produces a loss you cannot explain to a customer.
The real problem is not the loss itself; it is not noticing the loss.
class Quote {
String symbol;
double bid, ask;
long sourceTime; // provider's stamp
long arrivalTime; // when we received it
long sequence; // increasing number from the provider
}
// BEFORE opening an order:
long age = now() - quote.arrivalTime;
if (age > STALE_THRESHOLD_MS) { // e.g. 500 ms
reject("stale quote: " + age + " ms");
}
And the loss itself should be measured: if the provider gives an increasing
sequence number, count the gaps. A ticks_lost_total metric moves
"is the network fine?" from guesswork to data. Loss being acceptable does not mean
it should go unmeasured.
At-least-once: the fill that arrived twice
Now the real subject. Orders, fills, balance movements — losing these is not acceptable, so you use at-least-once. Which means they will arrive twice.
How it happens, one by one:
| Scenario | What happens |
|---|---|
| The acknowledgement was lost | The bridge sent the fill, you processed it, the connection dropped while acking. The bridge resends. |
| The consumer crashed mid-processing | You took the message, wrote to the database, and the pod died before acking. The message is still in the queue. |
| The user clicked twice | They saw a timeout, got impatient, clicked again. Two separate order requests. |
| A rebalance | A worker died, its work was taken over, and a half-processed message starts again. |
All four are normal operation. None of them is a bug. So chasing "make sure it does not arrive twice" is the wrong path; the right one is answering "what happens if it arrives twice?"
Why exactly-once is a myth (and what actually exists)
When you hear "we support exactly-once", the question to ask is: in delivery, or in processing?
- Exactly-once delivery: not possible. The ambiguity above — was it the ack or the message that was lost — does not go away.
- Exactly-once processing: possible. And the method is known: at-least-once delivery + a consumer that discards duplicates.
Kafka's exactly-once falls into the second category, with one condition: both the read and the write must be inside Kafka. If you read from Kafka and write to PostgreSQL, that guarantee does not cover you — you are now in dual write territory.
Cure 1: an idempotency key (request path)
The side sending the order generates a unique key per logical order and sends the same key on retries.
-- Key table, unique constraint is essential
CREATE TABLE request_keys (
key text PRIMARY KEY, -- client_order_id
account_id bigint NOT NULL,
result jsonb, -- a copy of the first response
created timestamptz NOT NULL DEFAULT now()
);
BEGIN;
INSERT INTO request_keys (key, account_id)
VALUES (:client_order_id, :account)
ON CONFLICT (key) DO NOTHING
RETURNING key;
-- If no row came back: this request was already processed.
-- -> DO NOT act, return the stored result.
-- If a row came back: first time, place the order
INSERT INTO orders (...) VALUES (...);
UPDATE request_keys SET result = :response WHERE key = :client_order_id;
COMMIT;
Two subtleties, both learned the hard way:
- The client generates the key, not you. Generate it server-side and every retry produces a new key, so the protection never fires.
- Store the first response and return it again. If you return an "already exists" error, the client reads it as a failure and retries again. The second request must receive the first request's answer.
Cure 2: an idempotent consumer (event path)
For a fill notification arriving on a queue there is no client; the key travels inside
the event (fill_id, deal_id, event_id).
The consumer checks whether it has seen that id.
BEGIN;
INSERT INTO processed_events (event_id, kind)
VALUES (:fill_id, 'FILL')
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;
-- No row? This fill was already processed -> COMMIT and exit
-- First time: do the real work
INSERT INTO fill_records (...) VALUES (...);
UPDATE positions
SET lots = lots + :lots, avg_price = ...
WHERE id = :position_id;
COMMIT;
The critical part: the dedup record and the real work are in the same transaction. Split them and a crash in between produces "marked as processed but never done" — which is worse than double processing, because nobody notices missing data.
The dedup table does not grow forever
With millions of events a day, that table becomes your main load a year later. Practical approach:
- Pick a window. Duplicates arrive within minutes, not months. Seven days is more than enough.
-
Clean up the old. A daily job:
DELETE FROM processed_events WHERE created < now() - interval '7 days'. Index the date column, otherwise the cleanup locks the table. - Think about outside the window. A duplicate arriving after the window will be reprocessed. If that is unacceptable, add a unique constraint on the real table too — a second line of defence.
So which guarantee for which data?
The easiest way to decide is one question: is losing this message worse, or processing it twice?
- Placing / cancelling orders
- Fills, partial fills
- Balance and margin movements
- Reconciliation records
- Price ticks, depth updates
- Live P&L broadcasts
- System metrics
- UI live updates
Note: both exist in the same system, and they should. Making the price feed at-least-once kills latency; making orders at-most-once kills money. Standardising on one guarantee means being wrong on one of the two sides.
From the field: how we closed the double fill
Back to the opening incident. The same fill arrived twice and two records were created. The first instinct was "fix the bridge so it stops sending twice". Wrong instinct: the bridge was behaving correctly, resending because it got no ack.
What we actually did:
-
A unique constraint on
deal_id. Not a check in the application, a prohibition in the database. However many copies run, the rule lives in one place. - Dedup and the position update moved into the same transaction. They had been separate, and a crash in between had once produced "processed but not written" — harder to spot than a duplicate.
-
The duplicate counter became a metric.
duplicate_events_totalis not zero and we do not expect it to be. But when the number jumps, it is usually the first sign of a problem on the bridge or the network. So dedup is not only a guard, it is also a sensor.
Checklist AI agent task list
- In this flow, is loss worse or duplication? Is the answer written down?
- If at-most-once: does the data carry an age and a staleness threshold?
- Is the loss measured (sequence gaps)?
- If at-least-once: is the consumer ready to see the same event twice?
- Are the dedup record and the real work in the same transaction?
- Is uniqueness enforced in the application or in the database? (It should be the database.)
- Does the client generate the idempotency key?
- Does a repeated request receive the same response as the first?
- Is there a cleanup job for the dedup table?
Conclusion
Choosing a delivery guarantee looks like a technical preference but it is really a commercial one: what are you willing to lose? You accept loss on prices because a new one is coming. You do not accept it on fills because that is money.
And one sentence to keep: exactly-once is not a delivery guarantee, it is a
consumer design. Nobody can sell it to you; you write it yourself, in about
eight lines of ON CONFLICT DO NOTHING.