Absorb campaign bursts, survive provider outages, and never let a marketing blast delay a one-time password.
Notification Platform
Outbox to priority-split Kafka topics, quota-aware workers behind provider adapters, delay topics and a DLQ.
- 50M/day
- notifications
- 5.8k/s
- peak intake
- 5 s
- p99 target for an OTP
This design builds on production experience moving SMS, email and WhatsApp notification flows onto Kafka. The case study itself is a fresh, general design; nothing here describes a specific employer’s system.
1. Problem and requirements
Internal services (orders, payments, claims, marketing) call one platform to notify people. The platform owns templates, preferences, provider integrations, retries and delivery tracking, so no product team writes its own SMS client again.
Functional requirements
| # | Requirement |
|---|---|
| F1 | Accept a notification for a recipient through one or more channels: SMS, email, WhatsApp, push. |
| F2 | Send immediately or at a scheduled time. |
| F3 | Render versioned templates with variables and locale. |
| F4 | Respect user preferences, opt-outs, quiet hours and legal consent per channel. |
| F5 | Prioritise: critical (OTP, security), transactional (order updates), bulk (marketing). |
| F6 | Choose a provider per channel, fall back to another provider when one fails. |
| F7 | Retry transient failures with backoff; park permanent failures in a dead-letter queue with a safe replay. |
| F8 | Track status through the provider’s delivery callbacks; expose status and history per notification. |
| F9 | Deduplicate requests and suppress notification storms to one recipient. |
| F10 | Enforce per-tenant (calling team) quotas. |
Non-functional requirements
| # | Requirement | Target |
|---|---|---|
| N1 | Intake durability | An accepted request (202) is never lost |
| N2 | Intake latency | p99 under 100 ms |
| N3 | Critical delivery latency (accept to provider hand-off) | p99 under 5 s, even during a bulk campaign |
| N4 | Transactional delivery latency | p99 under 60 s |
| N5 | Duplicate sends | As close to zero as the providers allow, and never from our own retries of a known-successful send |
| N6 | Intake availability | 99.95% |
| N7 | Audit retention | 13 months of status history |
The delivery guarantee, stated precisely
The platform guarantees at-least-once processing of every accepted request, with idempotent sending so that our own retries do not produce duplicates. It cannot guarantee exactly-once delivery to a person. If a worker crashes after a provider has accepted a message but before we record that, we cannot know whether the message went out. For providers that accept an idempotency reference, the retry is de-duplicated by the provider. For those that do not, a rare duplicate is possible and is preferred over a lost OTP. This trade-off is written into the API contract, so callers are not surprised.
Out of scope
In-app inbox, two-way conversational messaging, A/B testing of templates, and the marketing segmentation that decides who receives a campaign (the platform receives already-resolved recipients).
2. Capacity estimation
Assumptions: 50 million notifications a day; a bulk campaign can push intake to 10× the average for an hour; 3 status events per notification (accepted by provider, delivered, opened or failed); a stored notification with its rendered payload is about 1 KB; status events about 150 bytes.
| Metric | Working | Result |
|---|---|---|
| Average intake | 50,000,000 / 86,400 | ≈ 580 requests/s |
| Peak intake (campaign) | 580 × 10 | ≈ 5,800 requests/s |
| Provider send rate needed at peak | Bounded by provider quotas, not by us | e.g. SMS 1,000/s, email 2,000/s, WhatsApp 500/s, push 5,000/s |
| Status callbacks | 50 M × 3 / 86,400 | ≈ 1,740/s average |
| Notification storage per day | 50 M × 1 KB | ≈ 50 GB/day |
| Status storage per day | 150 M × 150 B | ≈ 22 GB/day |
| Hot storage (30 days) | (50 + 22) × 30 | ≈ 2.2 TB, partitioned by day |
| Archive (13 months, compressed ~5×) | 72 GB × 395 / 5 | ≈ 5.7 TB in object storage |
| Kafka throughput at peak | 5,800 × about 1 KB | ≈ 6 MB/s in, a few times that out across consumers |
What this says: intake must be faster than delivery, because providers cap how fast we can send. A campaign therefore builds a backlog by design, and the system must keep that backlog durable, drain it at exactly the provider’s rate, and keep critical traffic out of it. Kafka’s throughput is not the constraint; partitioning, priority isolation and provider quotas are.
3. Architecture and APIs
High-level architecture
flowchart LR S[Calling services] -->|POST /notifications| API[Intake API] API --> PG[(PostgreSQL: notifications, status, outbox)] PG --> OB[Outbox relay] OB --> T1[[notify.requested]] T1 --> RT[Router: preferences, templates, dedup, quotas] RT --> C1[[sms.critical]] RT --> C2[[sms.standard]] RT --> C3[[email.*]] RT --> C4[[whatsapp.*]] RT --> C5[[push.*]] C1 --> W1[SMS workers] C2 --> W1 W1 --> PA1[Provider adapters: SMS A / SMS B] C3 --> W2[Email workers] --> PA2[Email adapters] C4 --> W3[WhatsApp workers] --> PA3[WhatsApp adapter] C5 --> W4[Push workers] --> PA4[FCM / APNs adapters] W1 -->|transient failure| RQ[[retry.1m / 10m / 1h]] RQ --> W1 W1 -->|permanent or exhausted| DLQ[[dlq]] PA1 -->|webhooks| CB[Callback API] CB --> PG SCH[Scheduler] --> PG RL[(Redis: quotas, dedup keys)] --- RT RL --- W1
| Component | Responsibility |
|---|---|
| Intake API | Validates, authenticates the caller, applies the idempotency key, writes the notification and an outbox row in one transaction, returns 202. |
| Outbox relay | Publishes outbox rows to notify.requested and marks them sent. |
| Router | Loads preferences and consent, suppresses opted-out channels, renders the template, applies storm suppression and tenant quotas, picks a priority topic per channel. |
| Channel topics | One topic per channel and priority; isolates critical from bulk traffic and one channel from another. |
| Channel workers | Consume with bounded concurrency, take a provider quota token, call the adapter, record the result, route failures. |
| Provider adapters | Hide each provider’s API, error codes and idempotency mechanism behind one ChannelProvider interface. |
| Retry topics and DLQ | Delayed retries without blocking partitions; a dead-letter queue with full diagnostics and a replay tool. |
| Callback API | Receives provider delivery reports, verifies signatures, applies them through the state machine. |
| Scheduler | Releases scheduled notifications when they are due. |
| PostgreSQL | Source of truth for notifications and status history, partitioned by day. |
| Redis | Provider quota buckets (see 03 · Rate Limiter), dedup keys, storm counters. |
API contracts
| Method and path | Purpose | Success | Notable errors |
|---|---|---|---|
POST /notifications |
Accept one notification | 202 id and status ACCEPTED |
400, 409 DUPLICATE (same key, different body), 422 TEMPLATE_INVALID, 429 TENANT_QUOTA |
POST /notifications:batch |
Accept up to 1,000 | 202 with per-item results |
413 |
GET /notifications/{id} |
Status and history | 200 |
404 |
DELETE /notifications/{id} |
Cancel if not yet sent | 202 |
409 ALREADY_SENT |
POST /callbacks/{provider} |
Provider delivery reports | 204 |
401 bad signature |
PUT /recipients/{id}/preferences |
Opt in or out per channel and category | 200 |
POST /notifications
Authorization: Bearer <service token for team "orders">
Idempotency-Key: order-77812-shipped
{
"recipient": { "userId": "u_4821" },
"category": "ORDER_UPDATE",
"priority": "TRANSACTIONAL",
"channels": ["PUSH", "SMS"],
"fallback": "SEQUENTIAL",
"template": { "id": "order-shipped", "version": 3, "locale": "en-IN",
"vars": { "orderId": "77812", "eta": "Thursday" } },
"sendAt": null,
"expiresAt": "2026-10-23T18:00:00Z"
}
202 Accepted
{ "id": "ntf_01JB8…", "status": "ACCEPTED", "idempotencyKey": "order-77812-shipped" }
fallback: SEQUENTIALmeans: try push; if push is not delivered within its window, send SMS.ALLsends on every listed channel.expiresAtstops stale messages: an OTP that is 10 minutes old is dropped, not sent.
Deployment
flowchart LR
subgraph K8s [Kubernetes]
I[intake-api x 3, HPA on RPS]
R[router x 3]
WS[sms-workers, HPA on lag]
WE[email-workers, HPA on lag]
WW[whatsapp-workers]
WP[push-workers]
CBK[callback-api x 2]
RL[outbox-relay x 2, leader-elected]
end
subgraph Data
KF[[Kafka: 3 brokers, RF 3]]
PG[(PostgreSQL primary + standby)]
RD[(Redis)]
end
I --> PG
RL --> PG
RL --> KF
R --> KF
WS --> KF
CBK --> PG
Workers autoscale on consumer lag, capped by the partition count and by the provider quota: adding workers beyond the quota only adds rejected calls.
4. Internal design and workflows
Class diagram
classDiagram
class NotificationRequest {
+id
+tenant
+recipient
+category
+priority Priority
+channels List~Channel~
+template TemplateRef
+sendAt
+expiresAt
+idempotencyKey
}
class Delivery {
+notificationId
+channel
+attempt int
+state DeliveryState
+providerId
+providerMessageId
+transition(event) Delivery
}
class NotificationTemplate {
+id
+version
+channel
+locale
+render(vars) RenderedMessage
}
class PreferenceService {
+allowed(recipient, channel, category, now) Decision
}
class NotificationRouter {
+route(NotificationRequest) List~Delivery~
}
class NotificationDispatcher {
+dispatch(Delivery)
}
class ChannelProvider {
<<interface>>
+channel() Channel
+send(RenderedMessage, idempotencyRef) SendResult
+supportsIdempotency() bool
}
class TwilioSmsAdapter
class SecondarySmsAdapter
class SesEmailAdapter
class WhatsAppCloudAdapter
class FcmPushAdapter
class ProviderSelector {
+select(channel, delivery) ChannelProvider
}
class RetryPolicy {
+next(attempt, SendResult) RetryDecision
}
class DeliveryTracker {
+record(delivery, event)
+applyCallback(providerMessageId, status, at)
}
class QuotaGate {
+acquire(provider) bool
}
NotificationRouter --> PreferenceService
NotificationRouter --> NotificationTemplate
NotificationDispatcher --> ProviderSelector
NotificationDispatcher --> QuotaGate
NotificationDispatcher --> RetryPolicy
NotificationDispatcher --> DeliveryTracker
ProviderSelector --> ChannelProvider
ChannelProvider <|.. TwilioSmsAdapter
ChannelProvider <|.. SecondarySmsAdapter
ChannelProvider <|.. SesEmailAdapter
ChannelProvider <|.. WhatsAppCloudAdapter
ChannelProvider <|.. FcmPushAdapter
DeliveryTracker --> Delivery
Provider names are examples of the adapter pattern, not a recommendation of specific vendors.
Delivery state machine
stateDiagram-v2 [*] --> ACCEPTED ACCEPTED --> SUPPRESSED: opted out, quiet hours, storm limit ACCEPTED --> SCHEDULED: sendAt in the future SCHEDULED --> QUEUED: due ACCEPTED --> QUEUED: routed QUEUED --> SENDING: worker claims SENDING --> SENT: provider accepted SENDING --> RETRY_WAIT: transient failure RETRY_WAIT --> QUEUED: delay elapsed SENDING --> FAILED: permanent failure or retries exhausted SENT --> DELIVERED: provider callback SENT --> UNDELIVERED: provider callback (bounced, rejected) QUEUED --> EXPIRED: expiresAt passed RETRY_WAIT --> EXPIRED: expiresAt passed ACCEPTED --> CANCELLED: caller cancels QUEUED --> CANCELLED: caller cancels DELIVERED --> [*] UNDELIVERED --> [*] FAILED --> [*] SUPPRESSED --> [*] EXPIRED --> [*] CANCELLED --> [*]
Transitions are applied with a conditional update, so only legal moves succeed and a late event cannot drag a delivery backwards:
UPDATE delivery
SET state = :to, updated_at = :at, provider_message_id = coalesce(:pmid, provider_message_id)
WHERE id = :id AND state = ANY(:allowedFrom) AND updated_at <= :at;
The rank of each state (SENT < DELIVERED) plus the event timestamp lets an out-of-order callback (a SENT report arriving after DELIVERED) be recorded in history but ignored for the current state.
Data model and indexes
erDiagram
NOTIFICATION ||--|{ DELIVERY : "one per channel"
DELIVERY ||--o{ DELIVERY_EVENT : history
NOTIFICATION ||--o| OUTBOX : "published via"
TEMPLATE ||--o{ NOTIFICATION : renders
RECIPIENT_PREFERENCE }o--|| RECIPIENT : of
NOTIFICATION {
uuid id PK
text tenant
text idempotency_key
text recipient_id
text category
text priority
jsonb payload
timestamptz send_at
timestamptz expires_at
timestamptz created_at
}
DELIVERY {
uuid id PK
uuid notification_id FK
text channel
text state
int attempt
text provider
text provider_message_id
timestamptz updated_at
}
DELIVERY_EVENT {
bigint id PK
uuid delivery_id FK
text event
jsonb detail
timestamptz at
}
OUTBOX {
bigint id PK
text topic
text key
jsonb payload
timestamptz created_at
timestamptz published_at
}
TEMPLATE {
text id PK
int version PK
text channel
text locale
text body
}
RECIPIENT_PREFERENCE {
text recipient_id PK
text channel PK
text category PK
bool allowed
text quiet_hours
}
RECIPIENT {
text id PK
text phone
text email
text locale
}
-- Idempotent intake: one notification per caller and key
CREATE UNIQUE INDEX ntf_idem ON notification (tenant, idempotency_key);
-- Provider callbacks find their delivery
CREATE UNIQUE INDEX dlv_provider_msg ON delivery (provider, provider_message_id);
-- Scheduler: due work only
CREATE INDEX ntf_due ON notification (send_at) WHERE send_at IS NOT NULL;
-- Relay: unpublished outbox rows in order
CREATE INDEX outbox_pending ON outbox (id) WHERE published_at IS NULL;
notification, delivery and delivery_event are range-partitioned by day. Partitions older than 30 days are exported to object storage (Parquet) and dropped, which is far cheaper than DELETE.
Kafka topology
flowchart LR REQ[[notify.requested · 48 partitions · key recipientId]] --> RT[router group] RT --> SC[[sms.critical · 12p]] RT --> SS[[sms.standard · 24p]] RT --> SB[[sms.bulk · 24p]] SC --> W[sms-workers group] SS --> W SB --> W W -->|transient| R1[[sms.retry.1m]] W -->|2nd failure| R2[[sms.retry.10m]] W -->|3rd failure| R3[[sms.retry.1h]] R1 --> RW[retry consumer: waits until due, then republishes to sms.standard] R2 --> RW R3 --> RW W -->|permanent / exhausted / poison| D[[sms.dlq]] D --> OPS[DLQ console: inspect, fix, replay]
- Partition key
recipientId: messages to one person stay in order (the “order placed” SMS does not arrive after “order shipped”), and storm suppression for one person happens in one partition. - Priority by topic, not by message field: workers poll
criticalfirst with dedicated threads; a bulk backlog of millions sits insms.bulkand cannot delay an OTP. Kafka has no priority within a partition, so separate topics are the only reliable way. - Retry topics with fixed delays: a failed message moves to a delay topic and the main partition keeps flowing. Blocking retries inside the main consumer would stall every message behind the failing one.
- Commit after recording: a worker commits the offset only after the delivery state is written. A crash before that replays the message, and the state machine plus idempotency reference prevent a second send.
Dispatch sequence
sequenceDiagram
participant K as sms.standard
participant W as SMS worker
participant D as PostgreSQL
participant Q as QuotaGate (Redis)
participant P as Provider adapter
participant X as SMS provider
K->>W: Delivery d (attempt 1)
W->>D: UPDATE delivery SET state='SENDING' WHERE id=d AND state='QUEUED'
alt 0 rows (already sending or sent)
W->>K: commit offset, skip
else claimed
W->>Q: acquire(provider A)
alt no token
W->>K: pause partition briefly, retry acquire (backpressure)
end
W->>P: send(message, ref = d.id + attempt)
P->>X: HTTP send (timeout 3 s, client reference = ref)
alt accepted
X-->>P: messageId
W->>D: state SENT, provider_message_id
else transient (timeout, 5xx, 429)
W->>D: state RETRY_WAIT, attempt+1
W->>K: produce to sms.retry.1m
else permanent (invalid number, blocked)
W->>D: state FAILED, reason
end
W->>K: commit offset
end
X--)W: later: webhook DELIVERED (via Callback API)
Retry policy
RetryDecision next(int attempt, SendResult r) {
if (r.permanent()) return RetryDecision.fail(r.reason()); // bad number, unsubscribed
if (attempt >= 4) return RetryDecision.deadLetter("retries exhausted");
Duration base = List.of(Duration.ofMinutes(1), Duration.ofMinutes(10), Duration.ofHours(1)).get(attempt - 1);
long jitterMs = ThreadLocalRandom.current().nextLong(base.toMillis() / 5); // up to +20%
return RetryDecision.after(base.plusMillis(jitterMs), r.retryAfter()); // honour provider Retry-After if larger
}
Critical notifications use a shorter schedule (5 s, 15 s, 30 s) and switch provider on the first failure, because an OTP is worthless after a few minutes.
Storm suppression and deduplication
Two different problems with two different keys:
| Problem | Key | Mechanism |
|---|---|---|
| A caller sends the same request twice | (tenant, Idempotency-Key) |
Unique index at intake; a repeat returns the original 202 |
| A bug sends the same content to one person 50 times with different keys | (recipient, template, hash(vars)) |
Redis SET NX with a 10-minute TTL in the router; repeats become SUPPRESSED (DUPLICATE_CONTENT) |
| A legitimate flood to one person (20 order updates in a minute) | (recipient, channel) |
Token bucket, e.g. 5 SMS per 10 minutes for non-critical; excess is collapsed into a digest or dropped per category |
Transaction boundaries
| Step | Atomic unit |
|---|---|
| Intake | Insert notification + deliveries + outbox row: one database transaction |
| Publish | Relay reads outbox, produces to Kafka, marks published_at; a crash in between republishes (consumers de-duplicate by notification ID) |
| Send | Claim (QUEUED → SENDING) is one statement; the provider call is outside any transaction; the result is a second statement |
| Callback | Insert event + conditional state update: one transaction |
5. Failure modes and consistency
| Scenario | What would go wrong | Design response |
|---|---|---|
| Intake writes the DB, then Kafka publish fails | Accepted but never sent | Transactional outbox: the row is committed with the notification; the relay publishes it whenever Kafka is back. |
| Kafka redelivers a message (rebalance, crash before commit) | Duplicate SMS | Conditional claim QUEUED → SENDING; a message already SENT is skipped; provider idempotency reference covers the crash-after-send window where supported. |
| Worker crashes after the provider accepted but before recording | Unknown outcome | On redelivery, a delivery stuck in SENDING past a timeout is resolved by querying the provider by reference when the API allows; otherwise it is resent and the rare duplicate is accepted (documented trade-off). |
| SMS provider A outage | Messages pile up or fail | Circuit breaker per provider (open on 50% errors over 30 s); ProviderSelector routes to provider B; half-open probes restore A. Critical traffic fails over first. |
| Provider returns 429 (quota) | Retry storm makes it worse | Quota gate in Redis matches the provider’s contracted rate; on 429, the worker pauses the partition and honours Retry-After instead of retrying instantly. |
| Campaign floods intake | OTPs delayed | Separate critical topics and dedicated worker threads; tenant quotas cap bulk intake; bulk topics simply build lag. |
| Poison message (template renders an exception every time) | Partition stuck | Deserialisation and render errors go straight to the DLQ with the stack trace, payload and offset; the partition moves on. |
| Out-of-order callbacks | SENT overwrites DELIVERED |
State rank plus event timestamp; illegal backward moves are ignored for state but kept in history. |
| Forged callbacks | Fake “delivered” statuses | Provider signature verification (HMAC or provider-specific), IP allow-list, and replay protection by event ID. |
| Message outlives its usefulness | A 2-hour-old OTP arrives | expiresAt checked at claim time; expired deliveries become EXPIRED, never sent. |
| PostgreSQL primary fails | Intake down | Standby promotion; intake returns 503 with Retry-After meanwhile, and callers retry with the same idempotency key. Workers pause rather than send without recording. |
| Callback storm after a provider recovers | Database write spike | Callback API writes events in batches through its own small queue; state updates are idempotent so replays are safe. |
Consistency summary
- Strong (single database transaction): intake, idempotency, each state transition.
- Eventual: the Kafka pipeline from intake to send; status from provider callbacks.
- Never assumed: that Kafka delivers once, that a provider call that timed out failed, or that callbacks arrive in order.
Security and abuse
- Callers authenticate with service credentials; each caller (tenant) has quotas and an allow-list of templates and categories.
- Templates are reviewed and versioned; callers pass variables, never free-form message bodies, which stops the platform being used for arbitrary spam or phishing.
- Phone numbers and emails are personal data: encrypted at rest, masked in logs and traces, deleted or anonymised after retention.
- Opt-out (
STOPreplies, unsubscribe links) is processed as a preference update with the highest priority.
6. Scaling evolution
| Stage | Trigger | Design | Why |
|---|---|---|---|
| 1. Single application | Launch, under 50 req/s | One Spring Boot app: intake, a database-backed job queue (SELECT … FOR UPDATE SKIP LOCKED), one worker pool, one provider per channel |
A durable queue in PostgreSQL is enough and easy to operate; Kafka is not yet justified |
| 2. Growing traffic | Queue table contention, or several consumers need the stream | Outbox to Kafka; per-channel topics and workers; bounded concurrency; lag dashboards | Independent consumers, replay, and per-channel scaling |
| 3. Provider instability | Real outages and quota rejections | Second provider per channel, circuit breakers, quota gate, delayed retry topics, DLQ console | One provider’s bad day must not stop delivery |
| 4. Priority conflicts | Campaigns delaying transactional messages | Priority-split topics, dedicated critical workers, tenant quotas | Isolation by topic is the only real priority in Kafka |
| 5. Large multi-tenant | Many teams, billions per month | Per-tenant quotas and dashboards, partitioned status tables with archive, cost-aware routing (cheapest healthy provider per country) | Fairness, cost and retention dominate |
| 6. Multi-region | Regional data rules or latency | Regional intake and workers sending through regional providers; status replicated to a global view | Data residency for phone numbers and messages |
7. Operations and validation
Metrics and SLOs
| Signal | SLO or alert |
|---|---|
| Intake success rate | 99.95%; page on 5-minute burn |
| Critical accept-to-sent latency | p99 under 5 s; page on breach |
| Consumer lag and oldest unprocessed age per topic | Critical: alert at 10 s; standard: 2 min; bulk: dashboard only |
| Provider error rate and circuit state | Alert when a breaker opens |
| Delivery rate per channel (delivered ÷ sent) | Alert on a 20% drop versus the 7-day baseline (silent provider failure) |
| DLQ inflow | Alert above 0.1% of sends |
| Outbox oldest unpublished row | Alert at 30 s |
| Duplicate-suppression hits | Dashboard; spikes reveal caller bugs |
Traces carry the notification ID from intake through router, worker, adapter and callback, so one ID shows the whole life of a message.
Test plan (to build)
| Test | Proves |
|---|---|
| Kill a worker between provider accept and DB write (fault injection) | Behaviour of the crash window, with and without provider idempotency |
| Replay the same Kafka message 10 times | One send |
| Fake provider returns 503 for 2 minutes | Retries, breaker opens, failover to provider B, recovery |
Fake provider returns 429 with Retry-After |
Quota respected, no retry storm |
| Callbacks delivered in reverse order | Final state is the latest legal state |
| 1 million bulk plus a trickle of OTPs | OTP p99 stays under 5 s |
| Poison template | Lands in DLQ; partition keeps moving |
Load test (to measure)
Using fake providers with configurable latency, error rate and quotas: accepted requests per second, delivery throughput per channel, accept-to-sent p95 and p99 per priority, consumer lag over time, and behaviour during a simulated 5-minute provider outage. These will be charted and labelled as measured only after the runs exist.
8. Decision records
DR-1: Kafka, introduced at stage 2, not on day one
- Decision: start with a PostgreSQL-backed job queue; move to Kafka with an outbox when independent consumers, replay or per-channel scaling are needed.
- Rejected for day one: Kafka from the start (operational weight with no benefit below a few hundred messages a second). Rejected for later: RabbitMQ (good for task queues, but replay and multiple independent consumers of the same stream are first-class in Kafka).
DR-2: Priority by topic
- Decision: separate topics per channel and priority; workers drain critical topics first with dedicated capacity.
- Rejected: a
priorityfield on one topic (Kafka partitions are FIFO, so a bulk backlog still blocks critical messages behind it).
DR-3: Non-blocking retries through delay topics
- Decision: failed messages move to
retry.1m,retry.10m,retry.1h, then the DLQ. - Rejected: retrying inside the consumer (one bad provider stalls the whole partition);
Thread.sleepin a consumer (triggers rebalances).
9. Limitations, and implemented versus proposed
Known limitations
- Exactly-once delivery to a person is impossible; a rare duplicate is possible with providers that lack idempotency support.
- Per-recipient ordering holds within one channel, not across channels (push and SMS can arrive in either order).
- Delivery and open tracking depend on providers; email opens in particular are unreliable signals.
Status
| Item | Status |
|---|---|
| Requirements, delivery guarantee, capacity, HLD, APIs, deployment | Designed here |
| Class, ER, state, sequence and Kafka topology diagrams; retry and suppression logic | Designed here |
| Failure scenarios, consistency, security, scaling, SLOs | Designed here |
| Decision records, trade-offs, limitations | Designed here |
| Java implementation (intake, router, one channel worker, two fake providers) | To build |
| Fault-injection, ordering and replay tests | To build |
| Load test with provider outage | To measure |