Design notification deduplication to prevent duplicate sends at scale
Multiple triggers can fire the same notification: producer retries after timeout, multiple event sources for the same business event, Kafka consumer rebalance replaying messages. How do you prevent sending the same notification twice to a user while maintaining at-least-once processing guarantees from the queue?
What the deduplication layer must do
Quality constraints for the deduplication middleware
| Property | Target | Why It Matters / Design Impact |
|---|---|---|
| Zero Duplicates | 0 duplicate sends to user | User must never receive the same notification twice. SETNX is atomic — only one worker can win. Losers silently skip. |
| Check Latency | <0.5ms | Single Redis SETNX command. On the critical send path — every extra ms multiplied by 58K/sec = significant CPU time. |
| Dedup Window | 24 hours | Covers all retry scenarios (max retry window is typically 1 hour). 24h gives large safety margin. TTL auto-cleans. |
| Memory Efficiency | ~500GB for 5B keys/day | 5B keys × 100 bytes avg = 500GB. Redis cluster with 64 shards. TTL ensures steady-state (not growing unbounded). |
| Exactly-Once Semantics | From user's perspective | System is at-least-once internally (retries on failure). Dedup layer converts to exactly-once from user's view. |
| Availability | 99.99% | If dedup layer is down, decision: fail-open (risk duplicates) or fail-closed (risk dropping). Transactional → fail-open. |
Sizing the dedup infrastructure for 5B notifications/day
| Step | What to Derive | Calculation | Result | Design Decision |
|---|---|---|---|---|
| 1 | Dedup checks/sec | 5B / 86,400s | ~58K checks/sec | Redis cluster handles 100K+ SETNX/sec per shard. 64 shards = 6.4M ops/sec capacity. |
| 2 | Active dedup keys (steady state) | 5B keys × 24h window / 24h = 5B active | 5B active keys | At any moment, 5B keys exist in Redis (rolling 24h window). |
| 3 | Memory for dedup keys | 5B keys × 100 bytes (key + overhead) | ~500GB | Redis cluster: 64 shards × 8GB each. Each shard holds ~78M keys. |
| 4 | Duplicates suppressed/day | 5B × 3% raw duplicate rate | ~150M dupes blocked/day | Without dedup: 150M duplicate notifications sent. With dedup: ~0. |
| 5 | Dedup key size | SHA-256(user_id + template_id + params) = 32 bytes + prefix | ~50 bytes per key | Use SHA-256 truncated to 128 bits (16 bytes) for space efficiency. Collision probability negligible at 5B. |
| 6 | TTL expiration rate | 5B keys expire per day = ~58K expirations/sec | 58K expires/sec | Redis lazy expiration handles this. No explicit cleanup needed. Active expiration runs in background. |
| 7 | Hash computation overhead | SHA-256 at ~500MB/sec throughput, 200 bytes input | ~0.4μs per hash | Negligible CPU overhead. Compute hash in-process before Redis call. |
Deduplication is internal middleware — not user-facing. Integrated into the send path.
{
"user_id": "u_abc123",
"template_id": "order_shipped",
"params": { "order_id": "ORD-9876" },
"channels": ["push", "email"],
"dedup_key": "order_shipped:ORD-9876:u_abc123", // explicit override
// If dedup_key omitted, system generates: SHA256(user_id + template_id + sorted(params))
"dedup_ttl_sec": 86400 // optional: custom TTL (default 24h)
}
// Response when first send:
202 Accepted: { "notification_id": "n_001", "status": "queued", "dedup": "new" }
// Response when duplicate detected:
200 OK: { "notification_id": "n_001", "status": "already_sent", "dedup": "duplicate", "original_sent_at": "..." }
// Pseudocode — runs inside every worker before send
function checkDedup(notification):
dedup_key = notification.dedup_key
|| sha256(notification.user_id + notification.template_id + sortedHash(notification.params))
// Atomic: SET if NOT EXISTS, with TTL
result = redis.SET("dedup:" + dedup_key, notification.id, NX=true, EX=86400)
if result == OK:
return PROCEED // first time seeing this key → send notification
else:
return SKIP // key already exists → duplicate → skip silently
log.info("Dedup suppressed", {dedup_key, original: redis.GET("dedup:" + dedup_key)})
function generateDedupKey(user_id, template_id, params):
// Sort params for deterministic hashing (order-independent)
sorted_params = JSON.stringify(sortKeys(params))
// Concatenate with separator to avoid collisions
input = user_id + "|" + template_id + "|" + sorted_params
// SHA-256 truncated to 128 bits (sufficient for 5B daily notifications)
hash = sha256(input).substring(0, 32) // 32 hex chars = 128 bits
return "dedup:" + hash
// Example:
// user_id: "u_abc123", template: "order_shipped", params: {"order_id": "ORD-9876"}
// → "dedup:a7f3b2c1d4e5f6a7b8c9d0e1f2a3b4c5"
Minimal storage — just the dedup key and metadata
// Each dedup key is a simple Redis key with TTL
// Key: dedup:{hash}
// Value: notification_id (for audit/debugging)
// TTL: 24 hours (86400 seconds)
SET dedup:a7f3b2c1d4e5f6a7b8c9d0e1f2a3b4c5 "n_001" NX EX 86400
// NX = Set only if Not eXists (atomic check-and-set)
// EX = Expire in seconds
// Returns OK if set (first time) or nil if already exists (duplicate)
// Memory per key:
// Key: "dedup:" + 32 chars = 38 bytes
// Value: notification_id ~20 bytes
// Redis overhead: ~40 bytes (dict entry, expire entry)
// Total: ~100 bytes per key
CREATE TABLE delivery_log (
notification_id UUID PRIMARY KEY,
user_id TEXT,
template_id TEXT,
dedup_key TEXT, -- the hash used for dedup
status TEXT, -- sent | dedup_suppressed
attempts INT, -- how many times this dedup_key was seen
first_seen_at TIMESTAMP,
last_seen_at TIMESTAMP,
sent_via TEXT -- channel that actually delivered
);
// Query: "How many duplicates were suppressed for notification N?"
// SELECT attempts FROM delivery_log WHERE notification_id = ?
// Optional: track active dedup keys per user for debugging
// Sorted set: score = timestamp, member = dedup_key
ZADD active_dedup:u_abc123 1705312200 "a7f3b2c1d4e5f6a7b8c9d0e1f2a3b4c5"
// Cleanup: ZREMRANGEBYSCORE active_dedup:{user_id} 0 (now - 86400)
// Usage: "Show me all dedup keys for this user in last 24h"
// Useful for support debugging: "Why didn't my notification arrive?"
// The dedup key uniquely identifies "same notification to same user"
// Components:
// 1. user_id → same user
// 2. template_id → same notification type
// 3. params_hash → same parameters (order_id, etc.)
// Examples of SAME dedup key (duplicates):
// Send #1: user="u_abc", template="order_shipped", params={order: "ORD-9876"}
// Send #2: user="u_abc", template="order_shipped", params={order: "ORD-9876"} ← DUPLICATE
// Examples of DIFFERENT dedup keys (not duplicates):
// user="u_abc", template="order_shipped", params={order: "ORD-9876"}
// user="u_abc", template="order_shipped", params={order: "ORD-9877"} ← different order
// user="u_def", template="order_shipped", params={order: "ORD-9876"} ← different user
Event → Generate dedup_key → Redis SETNX → If new: send → If exists: skip
Tradeoffs for deduplication at scale
| Decision | Options Considered | Chosen | Why |
|---|---|---|---|
| Dedup Check Location | At producer, at queue, at worker (consumer) | At worker (just before send) | Catches ALL duplicate sources: producer retries, Kafka replays, multiple sources. Latest possible check = highest accuracy. |
| Hash Algorithm | MD5, SHA-256, MurmurHash3, xxHash | SHA-256 truncated to 128 bits | Cryptographic strength unnecessary but SHA-256 is universally available. 128 bits = collision probability ~10⁻¹⁸ at 5B keys. |
| Storage Backend | Redis, Memcached, DynamoDB, Bloom filter | Redis with SETNX + TTL | SETNX is atomic (no race conditions). TTL provides automatic cleanup. Persistence optional (dedup is ephemeral). Sub-ms latency. |
| Dedup Window | 1 hour, 6 hours, 24 hours, 7 days | 24 hours (configurable) | Covers all retry windows with margin. Max retry backoff is typically 1 hour. 24h handles delayed event sources too. |
| Failure Mode | Fail-open (send anyway), fail-closed (suppress) | Fail-open for transactional, fail-closed for marketing | If Redis is down: better to send a possible duplicate OTP than miss delivery. But duplicate marketing email is worse than missing one. |
| Key Composition | notification_id only, user+template+params, custom key | user + template + params (with custom override) | notification_id is unique per attempt (useless for dedup). Content-based key catches semantically identical notifications from different sources. |
Edge cases specific to deduplication at scale
| Scenario | Problem | Solution | Recovery |
|---|---|---|---|
| Redis Cluster Failover | Redis primary fails, some dedup keys lost during failover window (~1-5s). | Accept brief duplicate risk during failover. Redis Sentinel promotes replica. Lost keys = potential duplicates for ~5s window only. Acceptable trade-off vs. blocking all sends. | ~5s exposure window |
| Param Order Sensitivity | {a:1, b:2} and {b:2, a:1} produce different hashes → missed dedup. | Sort params keys before hashing. JSON.stringify(sortKeys(params)) guarantees same hash regardless of key order in the input object. | Deterministic by design |
| TTL Too Short | Dedup key expires at 24h. Delayed retry at 25h creates a duplicate. | TTL should exceed max possible retry window. If retries can happen after 24h, extend TTL to 48h (at 2× memory cost). Monitor max retry delay. | Extend TTL based on retry policy |
| Legitimate Re-send | User requests "resend OTP" — same template, same user, same params (new code differs). | OTP code is part of params → different params = different dedup_key. Or: producer sends explicit dedup_key with timestamp component for "allow re-send" semantics. | Natural — params differ |
| Memory Pressure | Redis approaching memory limit with 5B active keys. | Redis maxmemory with volatile-ttl eviction policy. Evicts keys closest to expiry first. Add shards if consistent pressure. Monitor key count vs. capacity. | Auto-evict oldest keys first |
| Dedup Across Regions | Multi-region deployment — same notification processed in US and EU simultaneously. | Global Redis cluster (high latency) OR accept brief regional dedup only (most duplicates are same-region retries). Cross-region dedup adds ~50ms — usually not worth it. | Regional-only dedup (accepted risk) |
5 key points for notification deduplication design interviews
SET key value NX EX 86400.