The Idempotent Consumer Pattern for Message Queues

Photo by Enoch Leung from Canada via Wikimedia Commons (CC BY-SA 2.0)
It is a way to make a message-queue consumer safe against duplicate deliveries. You record the ID of every message you process in a database table with a unique constraint, and you do that insert in the same transaction as the actual work. If the same message arrives again, the insert fails on the unique constraint, so you skip the work and acknowledge the duplicate harmlessly.
Because the acknowledgement can be lost in transit. If a consumer finishes work and sends an ack that never arrives, the broker cannot tell the difference between a slow consumer and a dead one. To avoid losing messages it redelivers, which is at-least-once delivery. True exactly-once across a network is provably impossible, so consumers must handle duplicates themselves.
Use a stable, producer-assigned business message ID, not the broker offset or delivery tag. Offsets can reset and delivery tags are scoped to a single connection, so neither survives redelivery or consumer rebalancing reliably. A message ID that the producer sets stays constant across every redelivery, which is exactly what deduplication needs.
A database transaction cannot roll back an HTTP call that already succeeded, so you push idempotency to the provider. Send an idempotency key derived deterministically from the message ID; most payment and messaging APIs deduplicate on that key and return the original result on retry. The IETF also has an active Standards Track draft for an Idempotency-Key HTTP header.
It will if you never clean it. You only need rows that are newer than your broker's maximum redelivery or retention window, which is typically hours. Run a scheduled delete that removes rows older than a safe margin, and the table and its unique index stay small and fast.

Photo by Enoch Leung from Canada via Wikimedia Commons (CC BY-SA 2.0)
Key Takeaway
Message brokers guarantee at-least-once delivery, so the same message will eventually arrive twice. The idempotent consumer pattern fixes this: record each processed message ID in a database table with a unique constraint, and apply that insert plus the side effect inside one transaction. Duplicates hit the constraint, roll back, and change nothing.
Every message queue I have run in production, from RabbitMQ to Kafka to SQS, makes the same promise: at-least-once delivery. Not exactly-once. That word matters. It means the broker will redeliver a message if it is not sure you finished processing it, and it is frequently not sure. A consumer crashes after doing the work but before acknowledging. A network blip drops the ack. A visibility timeout expires while you were still busy. In every case the broker does the safe thing and hands you the message again.
So duplicates are not an edge case you might hit. They are a certainty you must design for. If your consumer charges a card, sends an email, or decrements inventory, processing the same message twice is a real bug with real money attached. The idempotent consumer pattern is the standard, boring, correct answer, and it lives almost entirely in your database.
Exactly-once delivery across a network is impossible to guarantee, because the acknowledgement itself can be lost. If the consumer acks and the ack vanishes, the broker must choose: redeliver and risk a duplicate, or drop and risk losing the message forever. At-least-once picks the first, which is the only safe choice. What vendors market as exactly-once is really at-least-once delivery plus idempotent processing on the consumer side. Kafka's exactly-once semantics work inside the Kafka boundary; the moment you write to Postgres or call Stripe, you are back to needing your own idempotency.
Idempotent means running the operation N times leaves the system in the same state as running it once. That is the property you are engineering for, not zero duplicates. You cannot stop duplicates from arriving; you can make them harmless.
The whole pattern rests on one table. Each row is the fingerprint of a message you have already handled. The message ID column carries a unique constraint, which is the load-bearing element of the entire design. When a duplicate arrives, the insert violates that constraint and fails loudly, and that failure is exactly the signal you want.
CREATE TABLE processed_messages (
message_id TEXT NOT NULL,
consumer TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, consumer)
);
-- The composite key lets different consumers each
-- process the same message once, independently.Two design notes. First, key on the business message ID, not the broker offset or the delivery tag. Offsets reset, tags are per-connection; a stable producer-assigned ID survives redelivery and rebalancing. Second, include the consumer name in the key when multiple distinct consumers subscribe to the same topic, so each gets its own once-only guarantee.
The subtle mistake is to check the table, then process, then insert. That leaves a gap: two duplicate deliveries can both read empty, both process, both insert. The correct shape wraps the dedup insert and the business side effect in a single database transaction. If the insert succeeds, do the work and commit. If the insert hits the unique constraint, the message is a duplicate, so roll back and acknowledge it without doing anything. The atomicity is what makes it safe under concurrency.
// NestJS consumer, TypeORM data source
async function handle(msg: IncomingMessage): Promise<void> {
await dataSource.transaction(async (tx) => {
try {
await tx.insert(ProcessedMessage, {
messageId: msg.id,
consumer: 'billing-service',
});
} catch (err) {
if (isUniqueViolation(err)) {
// Already processed. Roll back, ack, move on.
return;
}
throw err; // real error -> nack -> redelivery
}
// Same transaction as the dedup row:
await tx.update(Order, msg.orderId, { status: 'PAID' });
});
}
// Postgres unique-violation SQLSTATE
function isUniqueViolation(e: unknown): boolean {
return (e as { code?: string })?.code === '23505';
}This only holds when the side effect and the dedup row share one transactional store. If your side effect is an external API call, the database transaction cannot roll it back. That is a different problem, covered below.
A local row update rolls back cleanly. Charging a card through Stripe does not; once that HTTP call succeeds, the money moved, and no transaction rollback un-charges it. Here you push idempotency out to the third party. Most serious payment and messaging APIs accept an idempotency key, and the IETF has an active Standards Track draft for an Idempotency-Key HTTP header, revision 07 as of late 2025. Send a stable key derived from the message ID; the provider deduplicates on their side and returns the original result on retry.
The processed-messages table grows forever if you let it. You do not need rows older than your broker's maximum redelivery window, which is usually hours, not months. Prune on a schedule and the table stays small and its unique index stays fast.
-- Nightly cron; keep a generous safety margin over
-- the broker's redelivery / retention window.
DELETE FROM processed_messages
WHERE processed_at < now() - INTERVAL '7 days';| Decision | Wrong way | Right way |
|---|---|---|
| Dedup key | Broker offset or delivery tag | Producer-assigned business message ID |
| Check vs insert | SELECT, then process, then INSERT | INSERT first, let the unique constraint decide |
| Transaction scope | Dedup row and side effect in separate commits | Both in one atomic transaction |
| External call | Assume the DB transaction protects it | Pass an idempotency key to the provider |
| Table growth | Keep every row forever | Prune past the redelivery window |
Most naturally idempotent operations need no table at all. Setting status to PAID or upserting a record by key is already safe to repeat. Reach for the processed-messages table when the operation accumulates, like incrementing a balance or appending a ledger entry.
Put together, the pattern is unglamorous and that is the point. Assume at-least-once delivery, key on a stable message ID, insert into a table with a unique constraint inside the same transaction as your side effect, push idempotency to any external system, and prune old rows. That is how you build exactly-once effects on a broker that only promises at-least-once, and it is the same shape whether you run Kafka, RabbitMQ, or SQS.