Make the operation safe instead
Keys are bookkeeping bolted onto an unsafe operation. Often you can make the operation itself safe to repeat instead, which means less machinery and fewer ways to fail.
Reach first for stating destinations instead of movements. Add 50 to the balance, applied twice, is corruption. Set this order to shipped, applied twice, is a Tuesday.
Whenever your operation can name the end state rather than the change, repeats collapse into doing nothing for free. Insert-or-update keyed on a natural identifier follows the same logic, so replaying a create-user event simply rewrites the same row.
Guard the changes you genuinely cannot restate as destinations. A conditional write is the standard tool: a version column, or a condition saying apply this only if the state is still what I expect. Your replay finds the condition false and does nothing.
Get the same guard with better ergonomics from a state machine. Allow shipped only from paid, and a redelivered ship-this-order event finds the order already shipped and falls straight through.
Queue consumers
Pay special attention to queue consumers, because at-least-once delivery makes duplicates routine. A consumer crashing after processing and before acknowledging guarantees a redelivery.
Use a table of processed events. Insert the event identifier and apply the state change inside the same database transaction, so a duplicate hits the uniqueness constraint and the transaction aborts cleanly.
The same-transaction detail is the entire trick. Tracking those identifiers in a separate store reopens the exact crash window you were trying to close.
Some side effects resist all of this. You cannot un-send an email or make a messaging provider forget what it delivered.
Push the protection to the boundary there. Record the intent to send under a deduplication key first, and have a single sender consume those records. You end up sending at most once, guarded by recording at least once, which is usually the right trade for notifications.
Worked example
Rohan's warehouse system consumes stock events from Kafka: received, picked, shipped, each adjusting an inventory count. Physical audits keep finding the numbers drift high by around 3 percent a week, always high, never low. The cause is consumer group rebalances: whenever a pod dies or deploys, a batch of events gets redelivered and the handler happily increments the same counts again. His fix is one migration and ten lines of code: a processed_events table with a unique constraint on event_id, and the insert wrapped in the same transaction as the count update. Redelivered events now die on the constraint before touching inventory. The next monthly audit comes back within 0.1 percent, and the team retires the Sunday recount shift that had quietly existed to paper over the drift.