> ## Documentation Index
> Fetch the complete documentation index at: https://notes.kodekloud.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Notification System The Duplicate Trap

> Explains preventing duplicate notification sends caused by queue redelivery using idempotency claims in Redis with atomic set-if-not-exists and owner tokens

We ran into a subtle but common failure mode when using a queue to drive notification delivery.

Example scenario:

* A worker pulls Alan's payment-notification ticket from the queue.
* The worker reads Alan's details from the database and calls the SMS provider.
* The SMS is delivered to Alan's phone.
* Before the worker can acknowledge the ticket to the queue, it crashes.

From the queue’s perspective the ticket was picked up and never acknowledged, so the queue re-delivers the same ticket to another worker. That second worker repeats the flow and the SMS is sent a second time — Alan receives duplicate notifications.

You can see the issue: a crash between the provider call and the queue acknowledgement can cause duplicate external deliveries. Relying on “careful” worker code is insufficient because a crash can always occur at the worst moment.

<Frame>
  <img src="https://mintcdn.com/kodekloud-c4ac6d9a/CjAdnUFCNZoN2k5-/images/System-Design-for-Interview-Prep/Fan-Out-One-Event-Many-Readers/Notification-System-The-Duplicate-Trap/duplicate-trap-queue-worker-sms-duplicates.jpg?fit=max&auto=format&n=CjAdnUFCNZoN2k5-&q=85&s=623fc58d87163863296c522ace8cc2b9" alt="A dark-themed system diagram titled &#x22;The Duplicate Trap&#x22; showing a notification server writing to a queue and worker(s) sending messages to an SMS provider, resulting in a phone receiving the same payment SMS twice. The flow highlights the queue, worker, and duplicate delivery issue." width="1920" height="1080" data-path="images/System-Design-for-Interview-Prep/Fan-Out-One-Event-Many-Readers/Notification-System-The-Duplicate-Trap/duplicate-trap-queue-worker-sms-duplicates.jpg" />
</Frame>

Pause and think: how can we prevent one notification from being sent two times?

The right approach is to accept that the queue can redeliver messages, and make duplicate processing harmless. Introduce a shared mechanism that detects and drops duplicates before calling the external provider — in other words, make processing idempotent.

High-level solution

* Give every notification a stable notification ID (stored in the DB).
* Use a fast shared store (e.g., Redis) to hold a temporary “claim” for that notification ID before calling the provider.
  * If a worker finds the claim already present, it drops the ticket (another worker is handling it).
  * If no claim exists, the worker creates one (claiming ownership), then calls the provider.
* Create the claim using an atomic "set-if-not-exists" with an expiry (TTL). The claim value should be a unique owner token so you can safely release it only if you own it.
* If the worker crashes after creating the claim but before calling the provider, the claim expires and another worker can retry later.
* If the provider call fails with an uncertain outcome, the worker removes its claim only if the owner token still matches (compare-and-delete). If the send succeeds, the worker updates the DB status to sent.

This small temporary claim is called an idempotency key. In this design the idempotency key is the notification ID; the stored value is the unique owner token.

Step-by-step sequence (recommended)

1. Worker pulls job from queue.
2. Read notification payload and notification ID from DB.
3. Generate a unique owner token (for example, a UUID).
4. Atomically attempt to create the idempotency claim with TTL: `SET key value NX EX <seconds>`.
   * Key = notification ID
   * Value = owner token
5. If SET NX EX succeeds:
   * Call the provider.
   * If call returns success:
     * Update DB notification status to `sent`.
     * Optionally refresh TTL while doing DB work to prevent expiry during long updates.
     * Remove the claim (best-effort) only if owner token still matches.
   * If call fails with a clear non-retryable error:
     * Remove claim only if owner token matches, then mark DB appropriately.
   * If call fails with uncertain outcome (timeout/no definitive response):
     * Remove claim only if owner token matches so another worker can retry.
6. If SET NX EX fails (claim already present):
   * Drop the job; some other worker is processing it.
   * Optionally back off and observe DB status to verify.

Important operational details

* Use an atomic set-if-not-exists with expiry (e.g., Redis `SET key value NX EX seconds`) when creating the claim.
* Store a unique owner token as the value so you can perform a safe compare-and-delete later:
  * Example safe delete Lua script for Redis:

```Lua theme={null}
-- compare-and-delete: delete key only if value matches the provided token
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0
end
```

* If the provider call succeeds, mark the notification as `sent` in the database.
* If the provider call fails with no clear outcome, remove the claim only if your owner token matches — this avoids deleting a new claim that another worker set after your claim expired.
* Choose the TTL long enough to cover the provider call and the DB update, or refresh the TTL while working. A TTL that is too short can allow duplicates if it expires before the send (or DB update) completes.

<Callout icon="lightbulb" color="#1CB2FE">
  Idempotency means that no matter how many times you perform an operation, the observable result is the same. Here, regardless of how many times a ticket is processed, the notification is sent at most once.
</Callout>

Failure modes and trade-offs

* Duplicate send (original problem) → solved by idempotency claims.
* Lost notification (new risk) → can happen if a worker writes the claim and crashes before the provider call completes; the TTL prevents permanent loss by allowing retries after expiry. Pick TTL carefully.
* Claim deletion race → solved by storing owner token and using compare-and-delete.
* Increased latency in worst case → a claim expiry can delay delivery by up to TTL; this is generally preferable to performing duplicate external sends.

Quick reference table

| Concern | Strategy | Example / Command |
| - | - | - |
| Claim creation | Atomic set-if-not-exists with expiry | `SET <notification-id> <owner-token> NX EX 60` |
| Safe claim removal | Compare-and-delete (only remove if owner token matches) | See Lua script above |
| External send success | Update DB notification status to `sent` | `UPDATE notifications SET status='sent' WHERE id = <id>` |
| Provider uncertainty | Remove your claim only if owner token matches, allowing retry | Use Lua compare-and-delete |

Implementation hints and tools

* Redis is a good fit for the idempotency store because it supports `SET NX EX` and Lua scripting for atomic compare-and-delete.
* Ensure worker clocks and TTLs are tuned to cover network and provider variability.
* Consider refreshing the TTL periodically while the provider call + DB update are still in progress to avoid accidental expiry.
* When generating owner tokens, use strong randomness (UUID v4 or similar).

<Callout icon="warning" color="#FF6B6B">
  Make sure the TTL covers the expected end-to-end time (provider call + DB update). If TTL is too short, the claim may expire and allow a duplicate send; if TTL is too long, recovery after a true failure can be delayed.
</Callout>

References

* Redis SET command: [https://redis.io/commands/set](https://redis.io/commands/set)
* Redis scripting and Lua: [https://redis.io/docs/manual/programmability/eval-intro/](https://redis.io/docs/manual/programmability/eval-intro/)
* Idempotency patterns in distributed systems (general reading)

With this idempotency-claim pattern the worst-case outcome becomes a slight delay (claim expiry + retry) rather than an accidental duplicate external delivery — a much safer and more user-friendly trade-off.

<CardGroup>
  <Card title="Watch Video" icon="video" cta="Learn more" href="https://learn.kodekloud.com/user/courses/system-design-for-interview-prep/module/36e67187-d5c7-44a8-9c92-65e2548b2f80/lesson/d3472630-233e-459b-8826-636d9a72faaf" />
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.