Why duplicates are unavoidable under at-least-once delivery

Most brokers promise at-least-once delivery, so a consumer must expect to see the same event more than once. An idempotent consumer produces the same end state no matter how many times it handles a given message. The inbox pattern is one way to get there: the consumer records each message ID it has processed in a table it owns, and it skips any ID it has already recorded.

Duplicates come from several ordinary failures:

  • Producer retries. The broker stores the message, but the acknowledgement is lost. The producer sends the message again.
  • Crash before ack. The consumer writes to its database and then dies before it acks or commits the offset. The message is delivered again on restart.
  • Rebalances. A partition moves to another consumer instance. That instance resumes from the last committed offset, so it reprocesses anything handled after that point.
  • Visibility timeouts. Processing runs longer than the queue's timeout. The message becomes visible again, and a second worker picks it up.

Some brokers advertise exactly-once semantics. That guarantee covers reads and writes inside the broker. Your database is outside that boundary, so the broker cannot make an INSERT in your service atomic with its offset commit.

This leads to the core rule: dedup is correct only when the inbox record and the business changes commit in the same local transaction. If they commit separately, a crash between the two commits either loses the work or lets a duplicate through.

Deduplication does not fix ordering. A consumer can still receive distinct messages out of sequence, and that problem needs its own design.

Choosing the deduplication key and designing the processed messages table

The deduplication key decides what counts as "the same event," so pick it before designing anything else.

Candidate key Strength Failure mode
Producer-assigned event ID Stable across redeliveries; cheap to compare Breaks if the producer regenerates it on retry or reuses it
Payload hash Needs no producer cooperation Two legitimate identical events collapse into one; a serializer change alters the hash
Business key (e.g. order ID + state) Matches domain semantics Fails when one entity legitimately emits the same transition twice

Prefer the producer-assigned ID, generated once when the event is created and persisted with it. A producer that mints a fresh ID on each send attempt defeats deduplication, because every retry looks new. A producer that reuses IDs, such as a counter reset after a restart, silently drops real events, which is worse because nothing errors.

Broker delivery IDs and offsets are usually the wrong key. They identify a delivery, not an event. A producer retry creates a new offset, and partition reassignment or topic replay changes them.

When several handlers consume the same event, put the handler name in the key. Otherwise the first handler's insert blocks the others. Use a composite primary key on (message_id, consumer), a processed_at timestamp, and optional type and status columns for debugging.

The primary key enforces uniqueness on insert. A separate index on processed_at keeps retention deletes from scanning the whole table. Avoid extra indexes, since each one slows the hot insert path. Never evict rows early as if this were a cache. The table is the correctness boundary, and losing a row too soon reopens the duplicate window.

-- PostgreSQL: inbox table keyed on the event, not the delivery.
CREATE TABLE processed_messages (
    message_id   uuid        NOT NULL,  -- producer-assigned, stable across retries
    consumer     text        NOT NULL,  -- handler name; each handler dedupes independently
    processed_at timestamptz NOT NULL DEFAULT now(),
    type         text,                  -- optional: event type, for debugging
    status       text,                  -- optional: e.g. 'done', for debugging
    PRIMARY KEY (message_id, consumer)  -- enforces uniqueness on insert
);

-- The only secondary index: lets retention deletes avoid a full scan.
CREATE INDEX processed_messages_processed_at_idx
    ON processed_messages (processed_at);

-- Inside the same transaction as the handler's business writes:
BEGIN;
INSERT INTO processed_messages (message_id, consumer, type, status)
VALUES ('3f6c2a1e-8b4d-4c7a-9e2f-1a5b6c7d8e9f', 'billing-handler',
        'OrderPlaced', 'done')
ON CONFLICT (message_id, consumer) DO NOTHING;
-- If the insert affected 0 rows, this handler already processed the
-- event: skip the business logic and COMMIT (or ROLLBACK) without side effects.
-- Otherwise apply the business writes here, then:
COMMIT;

-- Retention: keep rows longer than the longest possible redelivery or
-- replay window. Deleting earlier reopens the duplicate window.
DELETE FROM processed_messages
WHERE processed_at < now() - interval '30 days';

A PostgreSQL inbox table with a composite (message_id, consumer) primary key, an insert that detects duplicates per handler inside the business transaction, and an index-backed retention delete.

How it works: the transactional dedup algorithm step by step

The algorithm has five steps. Their order is what makes it correct.

  1. Begin a database transaction on the same connection the business logic will use.
  2. Insert the inbox row keyed by the deduplication key. In PostgreSQL, use INSERT ... ON CONFLICT DO NOTHING and check the affected row count. In databases without an insert-if-absent construct, catch the unique-constraint violation instead.
  3. If no row was inserted, another delivery already committed this message. Roll back, ack the broker, and return. This is the "already processed" signal. It is cheap because the unique index does the lookup.
  4. Run the business logic inside the same transaction. Its writes and the inbox row now share one fate.
  5. Commit, then ack.

Ack happens after commit because the two failure windows are not symmetric:

  • If the process dies after commit but before ack, the broker redelivers. Step 3 then catches the duplicate.
  • If the process acked before committing and then crashed, the message would be gone with no effect applied.

In the handler example that follows, look for one detail: the inbox insert and the domain writes run against the same transaction object.

The quiet bug

The common mistake is recording the message in its own transaction, or committing the inbox row before the handler finishes. Teams often do this to "claim" the message early.

The handler then throws, and its writes roll back, but the inbox row stays. On redelivery, step 3 sees the key and skips it. A transient failure has become a permanently dropped message.

Nothing errors and nothing alerts. The effect is simply missing. That is why this bug tends to surface weeks later during a reconciliation.

import psycopg2  # conn below is a psycopg2 connection

def handle(conn, message):
    # message: broker-agnostic wrapper exposing .id, .payload and .ack()
    processed = False
    # `with conn` is ONE transaction: commit on normal exit, rollback on exception.
    with conn:
        with conn.cursor() as cur:
            # Step 2: claim the key in the SAME transaction as the domain writes.
            cur.execute(
                "INSERT INTO inbox (message_id, received_at) VALUES (%s, now()) "
                "ON CONFLICT (message_id) DO NOTHING",
                (message.id,),
            )
            if cur.rowcount == 1:
                # Step 4: business logic on the same cursor/transaction.
                # If this raises, the inbox row rolls back too, so redelivery retries.
                cur.execute(
                    "UPDATE accounts SET balance = balance + %s WHERE id = %s",
                    (message.payload["amount"], message.payload["account_id"]),
                )
                processed = True
            # rowcount == 0 (step 3): an earlier delivery already committed.
            # Nothing was written, so the empty transaction is harmless.
    # Step 5: ack only after the commit above succeeded (or on a duplicate).
    message.ack()
    return processed

# The quiet bug, for contrast: claiming the key in its own transaction.
#   with conn:  # commits the inbox row immediately
#       cur.execute("INSERT INTO inbox ... ON CONFLICT DO NOTHING", ...)
#   with conn:  # if this fails, the inbox row is already committed
#       cur.execute("UPDATE accounts ...", ...)
# A crash in the second block leaves the key recorded, so every
# redelivery is skipped and the update is never applied.

A psycopg2 handler that inserts the inbox row with ON CONFLICT DO NOTHING and runs the domain write in the same transaction, then acks only after commit. A commented counter-example shows how a separately committed inbox row silently drops messages.

Concurrency and side effects outside the database

When two consumer instances receive the same message at the same moment, both open a transaction and try to insert the same key into the processed messages table. The unique constraint decides which one proceeds. The first insert succeeds and holds the new row uncommitted. The second insert hits that uncommitted row and blocks until the first transaction finishes. If the winner commits, the loser gets a unique-violation error. With an "on conflict do nothing" style insert, it instead sees zero rows affected and skips the handler. If the winner rolls back, the loser's insert succeeds and it processes the message. Either way, exactly one transaction applies the effect.

A check-then-insert approach does not have this property. If the consumer first queries "have I seen this key?" and inserts only when the answer is no, both instances can run the query before either inserts. Both see no row, and both run the handler. Only the constraint makes the check and the claim a single step.

The constraint protects only writes inside the same transaction. HTTP calls, emails and published events cannot be rolled back. If one of these runs and the commit then fails, the redelivered message repeats it. To handle this:

  • Pair the inbox with an outbox. Write outgoing events to an outbox table in the same transaction, and publish them afterwards.
  • Pass idempotency keys to external APIs. Derive the key from the message ID so that retries collapse on the provider's side.

When state spans multiple stores, such as a database and a cache, no single constraint covers both. You get eventual consistency, not exactly-once effects.

-- PostgreSQL. Inbox: one row per message this consumer has applied.
CREATE TABLE inbox (
  message_id   text PRIMARY KEY,  -- the unique constraint does the deduplication
  processed_at timestamptz NOT NULL DEFAULT now()
);

-- Outbox: side effects are recorded here and published after commit by a relay.
CREATE TABLE outbox (
  id              bigserial PRIMARY KEY,
  event_type      text  NOT NULL,
  payload         jsonb NOT NULL,
  idempotency_key text  NOT NULL,  -- derived from message_id, sent to downstream APIs
  published_at    timestamptz
);

-- RACY, do not use: two instances can both run this SELECT, both see no row,
-- and both run the handler before either one inserts.
--   SELECT 1 FROM inbox WHERE message_id = 'msg-7f3a9c';

BEGIN;
-- Claim the message. If another transaction has already inserted this key but
-- not yet committed, this statement blocks until that transaction ends.
--   Winner commits    -> no row returned: ROLLBACK, ack the message, skip the handler.
--   Winner rolls back -> one row returned: this transaction owns the message.
INSERT INTO inbox (message_id) VALUES ('msg-7f3a9c')
ON CONFLICT (message_id) DO NOTHING
RETURNING message_id;

-- Business write: commits or rolls back together with the claim.
UPDATE orders SET status = 'paid' WHERE id = 4211;

-- Do not call HTTP or email here. Record the intent, and let the relay publish it
-- after commit, passing idempotency_key so that retries collapse on the receiver.
INSERT INTO outbox (event_type, payload, idempotency_key)
VALUES ('order.paid', '{"order_id": 4211}', 'msg-7f3a9c:order.paid');
COMMIT;

A PostgreSQL inbox claim in which ON CONFLICT DO NOTHING serializes concurrent consumers on the primary key, while the outbox and its idempotency key cover side effects that cannot join the transaction.

Synchronous idempotent consumer vs stored inbox, and the alternatives

The synchronous idempotent consumer is the default for most services. It inserts the message ID and runs the handler in one transaction, then acknowledges the message once that transaction commits. It has no extra moving parts. The cost is that the broker waits on your handler, so a slow handler holds the message and the consumer's capacity.

A stored inbox splits that work in two. On receipt, the consumer inserts the raw message, keyed by ID, and acknowledges immediately. A worker later claims unprocessed rows and runs the handler. This keeps broker acknowledgement fast whatever the handler costs. It also lets you retry with backoff under your own control, and lets the worker process one aggregate's messages in order. You pay for it with a worker to operate, added end-to-end latency, and a lag metric to alert on. You also have to decide when a failing message becomes poison: after how many attempts, and where it goes next.

Approach Ack latency Ordering and retries Operational cost
Synchronous consumer Tied to handler Broker-defined Low
Stored inbox Insert only Your control, per aggregate Worker, lag, poison handling
Natural idempotency Tied to handler Broker-defined None beyond the write

Natural idempotency often makes both unnecessary. An upsert keyed on entity ID, an absolute update such as "set status to shipped", or a conditional write guarded by a version number all yield the same result when replayed. If every handler in a consumer is built this way, a dedup table only adds write load. A stored inbox is also overkill when handlers are fast and the broker's ordering is enough.

Retention, testing and observability

Sizing the dedup window

Retention must exceed the longest gap between a message's first delivery and its last possible redelivery. Add the broker's maximum redelivery or retention horizon, the slowest retry policy including dead-letter requeues, and any operational replay you might run, such as resetting a consumer offset by a week. If records are deleted after three days and someone replays seven days of history, the replayed events no longer match any stored key and are processed again. Either keep keys longer than any replay you allow, or treat long replays as a separate procedure that relies on idempotent handlers.

Deleting old rows in one large statement can hold locks and bloat the table while inserts are still arriving. Delete in small batches ordered by the timestamp index, or partition the table by day and drop expired partitions. Dropping a partition avoids row-level deletes entirely.

Tests that matter

Assert on business state, not on the dedup table:

  • Crash between commit and ack: kill the consumer after commit, redeliver, and confirm the balance or order changed once.
  • Concurrent redelivery: deliver the same message to two consumers at once and confirm one wins and the other finds the key.
  • Historical replay: replay a batch inside the retention window and confirm nothing changes.

Observability

Emit a duplicate counter labeled by consumer and message type. Alert when the rate departs from its baseline, because spikes often point to rebalances, timeouts or a misbehaving producer. Log the dedup key on every decision so you can trace a message during an incident.

-- PostgreSQL. Option A: plain table, purge in small batches.
-- Run repeatedly (cron/worker) until it deletes 0 rows; short transactions keep locks brief.
DELETE FROM inbox_message
WHERE (consumer, message_id) IN (
  SELECT consumer, message_id
  FROM inbox_message
  WHERE received_at < now() - interval '14 days'  -- window > max redelivery + allowed replay
  ORDER BY received_at                             -- served by index on received_at
  LIMIT 5000
);

-- Option B: partition by day and drop whole partitions.
-- A unique key on a partitioned table must include the partition column, so partition on a
-- value that is identical on every redelivery (the producer's event time), not on received_at.
CREATE TABLE inbox_message_p (
  consumer    text        NOT NULL,
  message_id  text        NOT NULL,
  produced_at timestamptz NOT NULL,  -- taken from the message itself, stable across redeliveries
  received_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (consumer, message_id, produced_at)
) PARTITION BY RANGE (produced_at);

CREATE TABLE inbox_message_p_2026_10_23 PARTITION OF inbox_message_p
  FOR VALUES FROM ('2026-10-23') TO ('2026-10-24');

-- Dedup check inside the business transaction: 0 rows inserted means duplicate; log the key.
INSERT INTO inbox_message_p (consumer, message_id, produced_at)
VALUES ('billing-consumer', 'evt-7f3a9c', '2026-10-23T09:15:00Z')
ON CONFLICT DO NOTHING;

-- Expire a day without row-level deletes: detach, then drop.
ALTER TABLE inbox_message_p DETACH PARTITION inbox_message_p_2026_10_09;
DROP TABLE inbox_message_p_2026_10_09;

PostgreSQL inbox retention, shown two ways: batched deletes ordered by timestamp, and daily partitions keyed on the producer's event time so the dedup key stays unique and expired days can be dropped without row locks.

Impact on cost and engineering time

Every message now costs one extra insert and one extra unique-index entry, written in the same transaction as the business change. That adds write latency and lock time to the consumer's critical path. At high throughput the processed messages table grows quickly. Time-ordered keys concentrate inserts on the last index page and create a hot spot. Random keys spread the inserts but cause page splits and index bloat.

Illustrative estimate (assumed numbers, not a benchmark)

  • Assumed rate: 2,000 messages/s
  • Assumed retention: 7 days (604,800 s)
  • Assumed row plus index size: 150 bytes

rows = rate × retention_seconds → 2,000 × 604,800 ≈ 1.21 billion rows

storage = rows × bytes_per_row → 1.21 × 10⁹ × 150 B ≈ 181 GB

That figure is why retention is a sizing decision and not cleanup trivia. Halving retention halves the table.

A stored inbox also adds a worker you have to run. Someone has to own its polling cadence, lag alerts, stuck-row handling and deploys.

The engineering time you get back usually outweighs these costs:

Area Cost added Time saved
Handler code Shared dedup step No per-handler ad hoc checks to write or review
Incidents Dedup metrics to watch Fewer "why did this charge twice?" investigations
Replays Storage for the retention window Re-consume a topic without hand-filtering events
Operations Inbox worker to run (stored variant) One audited path for every message type

Adoption checklist

  • Handlers have side effects that are not naturally idempotent.
  • Producers provide a stable, unique message ID.
  • The consumer's database can absorb one extra write per message.
  • You have defined a retention window longer than the maximum redelivery delay.
  • Someone owns the inbox worker's monitoring.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to inbox pattern microservices, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • The dependency graph answers "what does this touch?" in seconds instead of a manual audit.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • You can size a change's blast radius before merging, avoiding surprise cross-team firefighting.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.