System Design Case Study

Design notification deduplication to prevent duplicate sends at scale

🎯 Design a deduplication layer that prevents sending the same notification twice to a user, handling at-least-once delivery guarantees upstream, retry storms, and multiple event sources — all with <0.5ms check overhead at 5B notifications/day
Concepts Involved

Problem Statement

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?

Scope: Dedup key generation (content-hash based), atomic check-and-set via Redis SETNX, TTL-based dedup window management, handling retry paths safely. Complements notification service architecture as middleware on the send path — transparent to upstream producers.
5B/day
notifications processed
each needs dedup check
24hr
dedup window
TTL on dedup keys
0.1%
target duplicate rate
from 2-5% raw incoming dupes
<0.5ms
dedup check latency
single Redis SETNX call

Functional Requirements

What the deduplication layer must do

Must Have (Core)

1. Content-hash dedup key · generate deterministic key from user_id + template_id + params_hash
2. Atomic check-and-set · Redis SETNX ensures only first sender wins (no race conditions)
3. TTL-based window · dedup keys expire after 24 hours (configurable per notification type)
4. Transparent middleware · workers call dedup check before send, no upstream changes needed
5. Custom dedup key override · producers can specify explicit dedup_key for custom semantics
6. Safe retry handling · retries of the same notification use same dedup_key → safely rejected

Out of Scope (this design)

Upstream deduplication (Kafka exactly-once, producer idempotency)
Content-level dedup (same message different template → different notification)
Cross-channel dedup (push + email for same event is intentional, not duplicate)
Historical dedup (prevent "same promo sent last week" — that's frequency capping)
User-facing dedup UI (settings for dedup preferences)
Dedup analytics (how many duplicates were suppressed)

Non-Functional Requirements

Quality constraints for the deduplication middleware

PropertyTargetWhy It Matters / Design Impact
Zero Duplicates0 duplicate sends to userUser must never receive the same notification twice. SETNX is atomic — only one worker can win. Losers silently skip.
Check Latency<0.5msSingle Redis SETNX command. On the critical send path — every extra ms multiplied by 58K/sec = significant CPU time.
Dedup Window24 hoursCovers all retry scenarios (max retry window is typically 1 hour). 24h gives large safety margin. TTL auto-cleans.
Memory Efficiency~500GB for 5B keys/day5B keys × 100 bytes avg = 500GB. Redis cluster with 64 shards. TTL ensures steady-state (not growing unbounded).
Exactly-Once SemanticsFrom user's perspectiveSystem is at-least-once internally (retries on failure). Dedup layer converts to exactly-once from user's view.
Availability99.99%If dedup layer is down, decision: fail-open (risk duplicates) or fail-closed (risk dropping). Transactional → fail-open.
Key tension: Memory vs. Window Size. Longer dedup window = more protection but more memory. 24h window means storing all 5B daily keys simultaneously (~500GB). Shorter window (1h) uses ~20GB but misses duplicates from delayed retries. 24h is the standard for financial/transactional notifications.

Scale Estimation

Sizing the dedup infrastructure for 5B notifications/day

Given: 5B notifications/day · 24h dedup window · 2-5% raw duplicate rate · 0.1% target after dedup · <0.5ms per check
StepWhat to DeriveCalculationResultDesign 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.
Interview tip: The memory calculation is the "aha" moment. 5B keys × 100 bytes = 500GB is significant. If the interviewer pushes back on cost, discuss: (a) truncated hashes (50 bytes saves 50%), (b) shorter TTL for non-critical (1h instead of 24h), (c) probabilistic dedup (Bloom filter — accepts some false positives, meaning some notifications are wrongly suppressed).

APIs

Deduplication is internal middleware — not user-facing. Integrated into the send path.

POST /v1/notifications/send (with dedup_key)
Send notification with optional explicit dedup_key override for custom dedup logic
{
  "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": "..." }
Internal: Dedup Middleware (worker-level)
Called by every delivery worker before sending to provider
// 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)})
Internal: Dedup Key Generation
Deterministic key generation — same input always produces same 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"

Data Model

Minimal storage — just the dedup key and metadata

Redis: Dedup Keys (Primary Store)

// 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

delivery_log (Cassandra — audit trail)

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 = ?

Redis: Active Dedup Keys (SET for monitoring)

// 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?"

Dedup Key Composition

// 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

Architecture Overview

Event → Generate dedup_key → Redis SETNX → If new: send → If exists: skip

INCOMING EVENTS · May contain duplicates (2-5% rate) Original Event order_shipped:ORD-9876 Producer Retry ⚠️ same event, timeout retry Consumer Rebalance ⚠️ Kafka replays message Duplicate Source ⚠️ two services fire same event STEP 1: GENERATE DEDUP KEY · Deterministic content hash Input: user_id + template_id + params Sort params for deterministic output SHA-256 → truncate to 128 bits ~0.4μs computation dedup_key = "dedup:a7f3b2c1..." Same input always → same key STEP 2: REDIS SETNX · Atomic check-and-set (~0.3ms) SET dedup:a7f3... "n_001" NX EX 86400 NX = only if not exists | EX = 24h TTL Single atomic command — no read-then-write race Result: OK → FIRST (send it!) Result: nil → DUPLICATE (skip!) WHY SETNX IS PERFECT Atomic: no window between check and set. Two workers racing → only one wins. FIRST TIME → SEND Deliver to Provider APNs / FCM / SendGrid / Twilio Log delivery_log status: "sent", dedup_key DUPLICATE → SKIP SILENTLY ACK Kafka offset Don't reprocess this message Log suppression metric: dedup.suppressed++ RETRY PATH · Same dedup_key → safely rejected Worker crashes after send, before ACK Kafka re-delivers same message to another worker New worker generates same dedup_key Same user + template + params → same hash SETNX returns nil → SKIP ✓ (safe!) Notification already sent by first worker. No duplicate.

Key Design Decisions

Tradeoffs for deduplication at scale

DecisionOptions ConsideredChosenWhy
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.
Why content-hash and not notification_id? Each retry generates a new notification_id. But the CONTENT is the same (same user, same template, same params). The dedup key must be derived from what the USER sees, not from internal IDs. Two different notification_ids with the same content-hash are duplicates from the user's perspective.
The send-then-crash problem: Worker sends notification to APNs (success) → crashes before ACKing Kafka → Kafka redelivers to another worker → second worker generates same dedup_key → SETNX returns nil → skips. The user receives exactly one notification despite the crash. This is the core value of dedup at the worker level.
Dedup key collision risk: With 128-bit hash and 5B daily notifications, collision probability per day is ~5B² / 2¹²⁸ ≈ 10⁻²⁰. Even over 10 years, the probability of any collision is negligible. If the interviewer asks, explain the birthday paradox: you'd need ~2⁶⁴ entries for a 50% collision chance with 128-bit hash.
Cross-channel dedup is intentional: "Order shipped" sent to BOTH push AND email is NOT a duplicate. The dedup key includes the channel OR the dedup is checked per-channel-worker. Design decision: if the user subscribed to both push and email for order updates, they get both. Same notification, different channels = different user experience.

Resilience & Edge Cases

Edge cases specific to deduplication at scale

ScenarioProblemSolutionRecovery
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)

Interview Cheat Sheet

5 key points for notification deduplication design interviews

1. Content-Hash Key is the Core Insight
Don't use notification_id for dedup — each retry gets a new ID. Use SHA-256(user_id + template_id + sorted_params). This catches duplicates regardless of source: producer retries, Kafka replays, multiple event sources. Same content to same user = same hash = duplicate detected.
2. SETNX is Atomic — No Race Condition Possible
The naive approach (GET key, if not exists SET key, then send) has a race window between GET and SET where two workers both see "not exists" and both send. Redis SETNX (SET if Not eXists) is a single atomic command — exactly one worker wins. Combined with EX (TTL), it's a one-liner: SET key value NX EX 86400.
3. Dedup at Worker Level Catches Everything
Dedup at the producer only catches producer retries. Dedup at the queue only catches queue-level replays. Dedup at the worker (just before calling the delivery provider) catches ALL sources of duplicates because it's the last gate before the irreversible send action. This is the only position that guarantees zero duplicates to the user.
4. Memory Math: 500GB for 24h Window is Real
5B notifications/day × 100 bytes/key = 500GB Redis. This is expensive but justified for transactional notifications. Optimization levers: (a) shorter TTL for non-critical (1h = 20GB), (b) truncated hash (50 bytes saves 250GB), (c) Bloom filter for probabilistic dedup (3GB total but accepts ~0.1% false positive suppression).
5. Exactly-Once from User's Perspective (Not System's)
The system processes at-least-once (Kafka retries, worker retries). The dedup layer converts this to exactly-once from the user's perspective. This is the standard pattern: accept at-least-once internally for reliability, then dedup at the edge (just before external delivery) for user experience. Don't try to build true exactly-once end-to-end — it's impossibly expensive.