Both stores offer the one primitive deduplication needs — an atomic conditional write — so the choice comes down to four other properties: what happens to an acknowledged key when infrastructure fails, how expiry actually behaves, what it costs at your volume, and how much latency you are willing to add to a payment path. This guide sits under DynamoDB conditional writes for deduplication.
Prerequisites. A working atomic registration in at least one store, and a documented answer to what your endpoint does when the store is unreachable.
Step 1 — Decide whether a lost key is acceptable
This question decides the outcome more often than cost or latency does.
If a lost key means a duplicate charge, DynamoDB’s durability is worth its latency. If it means a duplicate email or a re-sent push notification, Redis’s speed is worth its window. Decide this before comparing anything else, because it is the only property neither store can be configured into.
Step 2 — The atomic primitive, side by side
# DynamoDB: a conditional PutItem. Rejected with ConditionalCheckFailedException.
try:
table.put_item(
Item={
"pk": scoped_key,
"fingerprint": fingerprint,
"state": "pending",
"expires_at": int(time.time()) + 86_400, # TTL attribute, epoch seconds
},
ConditionExpression="attribute_not_exists(pk) OR expires_at < :now",
ExpressionAttributeValues={":now": int(time.time())},
ReturnValuesOnConditionCheckFailure="ALL_OLD", # get the existing item free
)
outcome = "won"
except ClientError as e:
if e.response["Error"]["Code"] != "ConditionalCheckFailedException":
raise
existing = e.response["Item"] # replay from this
outcome = "replay"
# Redis: SET NX, or a Lua script for the richer state machine.
won = r.set(scoped_key, payload, nx=True, px=86_400_000)
outcome = "won" if won else "replay"
Note ReturnValuesOnConditionCheckFailure="ALL_OLD" — it returns the conflicting item as part of the failed write, saving a second round trip on the replay path. Without it, DynamoDB’s latency disadvantage roughly doubles for duplicates.
The OR expires_at < :now clause is not optional on DynamoDB, and it is the trap in step 4.
Step 3 — Model the cost
# Worked example: 2,000 registrations/sec, 24 h TTL, ~400-byte items.
rps, ttl_h, item_bytes = 2_000, 24, 400
writes_per_month = rps * 2_592_000 # 5.18 billion
live_items = rps * ttl_h * 3600 # 172.8 million
stored_gb = live_items * item_bytes / 1e9 # ~69 GB
# DynamoDB on-demand: 1 WCU per KB written; duplicates add a read on failure.
dynamo_write_usd = writes_per_month / 1e6 * 1.25 # ~$6,480
dynamo_store_usd = stored_gb * 0.25 # ~$17
# Redis: sized for peak, paid 24/7 — 3 shards × r7g.2xlarge with replicas.
redis_usd = 6 * 0.60 * 730 # ~$2,630
print(f"DynamoDB ≈ ${dynamo_write_usd + dynamo_store_usd:,.0f}/mo, Redis ≈ ${redis_usd:,.0f}/mo")
| Traffic shape | Cheaper store | Why |
|---|---|---|
| Steady, high throughput | Redis | Provisioned capacity is fully used; DynamoDB pays per request regardless |
| Spiky, low duty cycle | DynamoDB on-demand | No idle spend; Redis must be sized for the peak all month |
| Very large key space, low rate | DynamoDB | Storage is far cheaper than RAM per GB |
| Latency-critical payment path | Redis | 0.3–1 ms versus 8–15 ms p99; the cost difference is usually second-order |
Cost modelling here is directional, not authoritative — run it with your own item sizes and current regional prices before deciding. The shape of the answer is stable even as the numbers move.
Step 4 — TTL semantics differ sharply
This is the most common DynamoDB deduplication bug. DynamoDB’s TTL is a background sweeper, and AWS documents deletion as typically occurring within 48 hours of expiry — not at it. Expired items remain readable, countable and, crucially, still block a conditional write on attribute_not_exists(pk).
# WRONG: a key expired 30 hours ago still blocks the write.
ConditionExpression="attribute_not_exists(pk)"
# RIGHT: treat an expired item as absent, explicitly.
ConditionExpression="attribute_not_exists(pk) OR expires_at < :now"
# Reads must filter too — never trust absence to mean expired.
item = table.get_item(Key={"pk": scoped_key}).get("Item")
if item and item["expires_at"] < int(time.time()):
item = None # logically expired, physically still present
Redis has the opposite property: expiry is effectively immediate from the client’s perspective, and there is no lag to compensate for. In exchange, a Redis key can also disappear early under memory pressure unless maxmemory-policy is noeviction, as Redis Cluster vs Sentinel covers.
Step 5 — Partition-key design
DynamoDB spreads load by partition key, and a poorly chosen one creates a hot partition that throttles regardless of provisioned capacity.
# BAD: credential as the partition key. One large tenant saturates one partition.
{"pk": credential_id, "sk": f"{route}#{key}"}
# GOOD: the full scoped key as the partition key. Uniform by construction,
# because the idempotency key itself is high-entropy.
{"pk": f"idem#v1#{credential_id}#{route}#{key}"}
The deduplication access pattern is a pure point lookup — you never range-scan idempotency keys — so there is no reason to use a sort key at all. Putting the whole scoped key in pk gives perfectly uniform distribution for free.
If you do need to enumerate a tenant’s keys for an incident, add a sparse GSI on credential_id with a low projection rather than restructuring the primary key. The GSI costs writes but keeps the hot path uniform.
Verification and testing
Confirm the expiry clause actually works. Insert an item whose TTL has already passed and assert a fresh registration wins.
aws dynamodb put-item --table-name idempotency \
--item '{"pk":{"S":"idem#v1#probe"},"expires_at":{"N":"1"},"state":{"S":"completed"}}'
python3 -c "from store import register; print(register('idem#v1#probe','fp'))"
# expect: won — an expired item must not block the write
Confirm concurrency.
KEY="idem#v1#concurrency-$RANDOM"
seq 64 | xargs -P 64 -I{} python3 -c "
from store import register; print(register('$KEY','fp'))" | sort | uniq -c
# expect: 1 × won, 63 × replay
Confirm no hot partition under load.
aws cloudwatch get-metric-statistics --namespace AWS/DynamoDB \
--metric-name ThrottledRequests --dimensions Name=TableName,Value=idempotency \
--start-time "$(date -u -d '1 hour ago' +%FT%TZ)" --end-time "$(date -u +%FT%TZ)" \
--period 60 --statistics Sum
# expect: all zeros during a sustained load test
Failure scenarios and debugging
| Failure Scenario | Remediation Steps | Observability Hooks |
|---|---|---|
attribute_not_exists(pk) used alone, so logically expired keys block registrations for up to 48 hours |
Add OR expires_at < :now to every condition expression and filter expired items on read. |
idempotency_expired_blocked_total from a comparison of item age against TTL; alert on any value |
| Credential used as the partition key, and the largest tenant throttles a single partition while the table is far under capacity | Move the full scoped key into pk. Add a sparse GSI if enumeration is needed. |
ThrottledRequests above zero while ConsumedWriteCapacityUnits sits well below provisioned |
| Redis chosen for a payment path, and a failover loses 40 ms of keys, producing duplicate charges | Either move to DynamoDB, or keep Redis as a negative cache in front of a relational unique constraint that is the authoritative record. | Duplicate charges per distinct key measured across a forced failover — the number belongs in the degradation policy |
| DynamoDB latency added to a synchronous checkout path, pushing p99 past the budget | Register asynchronously where the operation allows it, or front DynamoDB with a Redis read-through cache for the replay path only — never for registration. | idempotency_registration_duration_ms p99 split by store; checkout latency_seconds p99 |
| On-demand billing adopted for steady 2,000 rps traffic, and the bill is several times a provisioned equivalent | Switch to provisioned capacity with autoscaling once the traffic shape is known to be steady. Re-model quarterly. | ConsumedWriteCapacityUnits variance over 30 days; a flat line means provisioned is cheaper |
SRE / observability checklist
idempotency_registration_duration_ms{store}— Histogram split by backend. DynamoDB p99 above25 msor Redis p99 above5 msindicates a problem in that store, not in the application.ConditionalCheckFailedExceptionrate — DynamoDB’s native duplicate signal. The healthy band is the same 0.5%–2% replay rate as any other store.ThrottledRequests— Must be zero. Any value with spare capacity means a hot partition.idempotency_expired_blocked_total— Counter of registrations blocked by an item past its TTL. Non-zero means the condition expression is missing the expiry clause.redis_evicted_keys_total— Must be zero on a Redis-backed store; an eviction is a silently deleted guarantee.idempotency_storelabel on every metric — So a migration between stores is observable and can be rolled back on evidence.
Related
- DynamoDB Conditional Writes for Deduplication — the parent page on DynamoDB’s conditional-write semantics.
- Using ConditionExpression for Atomic Idempotency — the expression syntax and error handling in depth.
- Redis Cluster vs Sentinel for Deduplication State — the Redis-side topology decision this comparison assumes.
- Redis vs PostgreSQL for Idempotency State — the third option, when the key belongs in the transactional database.