Redis vs Postgres for Webhook Idempotency Keys
The choice surfaces the first time you ask βwhere do I record that I have already processed event_id?β β and it is load-bearing, because the idempotency store is what makes a duplicate webhook a no-op instead of a double charge. This page is a decision companion under Outbox Pattern & Event Publishing: the outbox guarantees at-least-once delivery, which means duplicates are certain, which means the idempotency store is non-negotiable. The real question is whether to back it with Redis (fast, in-memory) or Postgres (durable, transactional). The honest answer is that atomicity with your ledger usually decides it.
Trade-offs
The decision hinges on one row of the table: can the dedup claim and the ledger effect share a transaction? Only Postgres can. Redis wins on raw latency but forces a two-phase claim-then-effect dance with a crash window. The map contrasts the two on the axis that actually decides it.
| Dimension | Redis | Postgres |
|---|---|---|
| Read/write latency | ~0.1β1 ms (in-memory) | ~1β10 ms (disk + WAL) |
| Durability default | Async (RDB/AOF can lose seconds) | Synchronous WAL, crash-safe |
| TTL handling | Native EX / SET NX EX, auto-eviction |
Manual: expires_at column + cleanup job |
| Atomicity with the ledger | Separate system β cannot share a txn | Same transaction as the ledger write |
| Atomic claim primitive | SET key val NX EX ttl |
INSERT ... ON CONFLICT DO NOTHING |
| Throughput ceiling | 100k+ ops/s single node | ~10kβ50k inserts/s before tuning |
| Failure blast radius | Cache loss β possible reprocessing | Bounded by DB availability |
| Operational cost | Extra service, memory-priced, eviction tuning | Reuses existing DB, grows table |
| Best when | Latency-critical, effect is external/idempotent anyway | Dedup must be atomic with ledger mutation |
The decisive row is βatomicity with the ledger.β If recording the event_id and mutating the ledger must succeed or fail together, only Postgres lets you put both in one transaction. Redis forces a two-phase dance (claim in Redis, then write the ledger) where a crash between phases either reprocesses or strands the key. Redis earns its place when the protected effect is itself idempotent or external, and sub-millisecond dedup matters at high volume.
Why the latency gap rarely decides it
The latency column is the row engineers reach for first, and it is almost always the wrong one to optimize against. A webhook handler that applies an invoice mutation is already paying for the ledger write, the balance recomputation, and usually an outbound API call to acknowledge the provider β the idempotency lookup is a rounding error inside a request that runs tens of milliseconds end to end. Saving 2ms on the dedup check while the surrounding transaction costs 40ms buys you nothing a customer or an SLA can perceive. The place where Redis latency genuinely matters is the duplicate-storm case: when a provider like Stripe replays a backlog and you receive the same event_id hundreds of times per second, a Redis SET NX rejects each replay for a fraction of the cost of opening a Postgres transaction, taking connection-pool pressure off the primary. If your duplicate rate is a few percent of a modest event volume, that pressure never materializes and the shared-transaction guarantee of Postgres is strictly the better trade.
What βthe ledgerβ actually means here
It is worth being precise about the effect the idempotency key protects, because that effect is what decides whether a two-phase claim is survivable. If processing event_id means inserting a row into ledger_entries with a customer_id, an invoice_id, and an amount_minor of, say, 4900, then the claim and the insert are mutations of the same Postgres instance and belong in the same BEGIN/COMMIT. If processing instead means calling a downstream payment API that is itself keyed on your idempotency_key, the durable claim is less load-bearing β the downstream system will collapse your duplicate for you β and Redis as a cheap first-line filter costs you little. The mistake is treating every webhook as the same shape. Classify each handler by whether its effect is internal-and-transactional or external-and-idempotent, and let that classification, not a benchmark, pick the store.
Step-by-Step Implementation
The two implementations differ in where the crash window sits. Postgres puts claim and effect in one transaction, so there is no window. Redis claims first and effects second, leaving a gap that the effect must tolerate. The hybrid uses Redis as a fast gate in front of the Postgres authority. The diagram contrasts the three.
1. Postgres: claim and mutate in one transaction
The unique constraint is the dedup. ON CONFLICT DO NOTHING ... RETURNING tells you in one round trip whether you won the claim, and the ledger write rides the same transaction.
CREATE TABLE consumed_events (
event_id UUID PRIMARY KEY,
consumed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + interval '3 days'
);
def process_with_postgres(event_id: str, payload: dict, db) -> None:
with db.transaction() as txn:
won = txn.execute(
"INSERT INTO consumed_events (event_id) VALUES ($1) "
"ON CONFLICT DO NOTHING RETURNING event_id", event_id)
if not won:
return # β
duplicate, no-op
apply_ledger_mutation(payload, txn) # commits atomically with the claim
2. Postgres: expire keys with a cleanup job
There is no native TTL, so age out rows past the provider retry window on a schedule.
DELETE FROM consumed_events WHERE expires_at < now(); -- run every few minutes
3. Redis: atomic claim with SET NX EX
SET NX makes the claim atomic and EX gives you free expiry matched to the retry window. Treat the effect carefully: it happens after the claim, so it must be safe to repeat if the process dies before completing.
import redis
r = redis.Redis()
RETRY_WINDOW = 3 * 24 * 3600 # match provider retry horizon
def process_with_redis(event_id: str, payload: dict) -> None:
claimed = r.set(f"idem:{event_id}", "1", nx=True, ex=RETRY_WINDOW)
if not claimed:
return # β
duplicate within window, skip
try:
apply_idempotent_effect(payload) # must tolerate at-least-once
except Exception:
r.delete(f"idem:{event_id}") # β οΈ release so a retry can re-attempt
raise
The RETURNING event_id clause is doing more work than it looks. Without it you would have to issue a second SELECT to learn whether your INSERT won the race, and that second round trip reopens a window where two workers both believe they lost. Because ON CONFLICT DO NOTHING RETURNING reports the winner in the same statement that performs the claim, the atomic decision and its result travel together. Note also that the winning worker holds a row lock on that event_id for the duration of the transaction, so a concurrent duplicate does not merely get DO NOTHING β it blocks until the first transaction commits or rolls back, then sees the committed row and correctly no-ops. That blocking is a feature: it serializes duplicates rather than letting them race the ledger write.
A subtle failure in the Redis release path
The r.delete in the exception branch of process_with_redis looks like clean compensation, but it hides a race. Suppose worker A claims idem:{event_id}, begins its effect, and stalls (a long GC pause, a slow downstream). The provider retries, worker B tries to claim, is correctly rejected, and drops the delivery assuming A will finish. Then A throws and runs r.delete, releasing the key β but B is already gone and will not come back until the next provider retry, which may be minutes away or, past the retry horizon, never. The key is released with no worker left to act on it. This is why the release-on-failure pattern is only safe when the effect is genuinely idempotent and the provider is still within its retry window; if either assumption is shaky, prefer the Postgres path where a rolled-back transaction leaves no claim and no orphaned work in the first place.
4. Optional: Redis as a fast gate in front of Postgres
A common hybrid uses Redis to absorb the duplicate flood cheaply, with Postgres as the durable store of record. Reconcile the two so a Redis flush cannot silently permit reprocessing.
def process_hybrid(event_id: str, payload: dict, db) -> None:
if not r.set(f"idem:{event_id}", "1", nx=True, ex=RETRY_WINDOW):
return # fast reject of obvious duplicates
process_with_postgres(event_id, payload, db) # durable, authoritative claim
The ordering here is deliberate and easy to get backwards. Redis is the gate, not the authority: a Redis hit (claim rejected) short-circuits, but a Redis miss (claim won) must still fall through to the Postgres claim, which is the only decision that counts. If you ever invert this and treat a Redis miss as permission to skip Postgres, a Redis flush or eviction turns every previously-seen event into a fresh one and you reprocess the entire retry window at once. The gate can only ever reject work it is certain about; it must never grant work on its own authority. The payoff is real, though β during a replay storm the vast majority of duplicates die at the Redis SET NX and never touch a database connection, which keeps the Postgres primary available for the genuinely-new events that need its transaction. Treat the Redis layer as a cache that can be cold at any moment: cold means slower, never means incorrect.
Verification & Testing
The tests prove atomicity, crash-safety, and correct TTL behavior for each store. The subtle one is the TTL test: after the provider stops retrying, a re-delivery should reprocess β the key expiring is correct, not a bug. The panel lists the tests.
Fire the same event_id concurrently from many workers and assert exactly one effect β this catches non-atomic claims. For Postgres, assert that a failed ledger mutation rolls back the consumed_events insert so the event can be retried. For Redis, simulate a crash after SET NX but before the effect and assert the key is released (or the effect is genuinely idempotent). Verify TTL: write a key, advance the clock past the window, and assert a re-delivery is reprocessed (correct, because the provider stopped retrying long ago).
Make the concurrency test hostile enough to actually fail a broken implementation. A loop that fires ten sequential requests will pass even against a naive check-then-insert with a race, because the requests never overlap. Spawn the workers against a real connection pool and gate them on a shared barrier so they all hit the claim within the same few microseconds, then run the whole thing a few hundred times β races are probabilistic, and a single pass proves nothing. Assert on the observable effect, not the claim: count the ledger_entries rows for that invoice_id, not the number of INSERT statements attempted, because the whole point is that many attempts collapse to one committed mutation. For the Redis crash test, do not rely on catching a Python exception; kill the process between the SET NX and the effect with a signal or a fault-injection hook, so you are testing what a real SIGKILL during deploy would do rather than what your try/except politely handles.
The drift query above deserves a permanent home as a monitored invariant, not a one-off debugging tool. Run it on a schedule and alert when it returns any row, because a ledger entry with no matching claim is the signature of exactly the bug this whole page exists to prevent: an effect that was applied without the dedup record that should have guarded it. In a healthy system it returns zero rows every time; the day it returns a customer_id you have caught a reprocessing incident while it is still small, before the double-charged customer opens a ticket.
-- Drift check: durable keys missing from the fast store (acceptable post-flush),
-- and any effect applied without a recorded claim (a bug)
SELECT le.event_id
FROM ledger_entries le
LEFT JOIN consumed_events ce ON ce.event_id = le.event_id
WHERE ce.event_id IS NULL
AND le.created_at > now() - interval '1 day';
Gotchas & Production Pitfalls
The pitfalls split by store: Redis ones are about durability and eviction silently reopening the duplicate window; Postgres ones are about unbounded growth; and the shared one is a TTL shorter than the retry horizon. The map groups them.
- Redis durability defaults can lose the claim. With async AOF/RDB, a node failure can drop seconds of keys, permitting reprocessing. Enable
appendfsync everysecat minimum, and never let Redis be the sole record of a financial dedup decision. - The two-phase Redis dance is not atomic. Claim-then-effect has a crash window. Either make the effect idempotent on its own or use Postgres so the claim and effect share a transaction.
- TTL shorter than the retry window reprocesses real duplicates. If the key expires before the provider stops retrying, a late retry sails through. Set the TTL to at least the full provider retry horizon (often 72 hours).
- Postgres without a cleanup job grows unbounded. The
consumed_eventstable never self-expires. A forgotten cleanup job bloats it and slows the unique-constraint check over time. - Eviction under memory pressure silently breaks dedup. A Redis
maxmemorypolicy ofallkeys-lruwill evict idempotency keys to make room, reopening the duplicate window. Use a dedicated instance ornoevictionwith capacity headroom.
Frequently Asked Questions
Can one store serve both purposes? Postgres alone is correct and slower; Redis alone is fast and loses the guarantee on eviction or flush. Two tiers is the usual answer because the two properties are genuinely different.
What happens on a Redis flush? Every key in the protection window disappears at once, and without a durable backstop the whole window reopens. That single scenario is the argument for the second tier.
Does the durable check add much latency? Only on a cache miss, which is the common path for a first delivery and cheap relative to the work the handler is about to do. Retries hit the fast store and never reach it.
Should the fast store be shared with other caches? Preferably not. An eviction policy tuned for a general cache will evict idempotency keys under memory pressure, which is precisely when traffic is high and duplicates are likely.