Overview
A notification service is the single place where every product team sends email, SMS, push and in-app messages. Centralising it avoids each team re-implementing provider integrations, retries and opt-outs, and it lets us enforce user preferences and rate limits in one place.
The hard parts are not sending a message. They are never losing an accepted notification, never sending the same one twice, keeping urgent messages (like login codes) fast while a million-user campaign runs, and surviving provider outages.
THE ONE-LINE DESIGN
Accept requests quickly and durably, then process them asynchronously through isolated priority lanes: an orchestrator applies preferences and templates, per-channel workers talk to providers with retries and failover, and a state machine tracks every notification to its final outcome.
Requirements
Functional
- Send via email, SMS, mobile push, web push and in-app
- Transactional (OTP, receipts) and marketing (campaigns)
- Templates with localisation and versioning
- User preferences, opt-out and quiet hours
- Scheduled and recurring sends
- Delivery status, receipts and webhooks back to callers
- Multi-tenant with per-tenant quotas
Non-functional
- Durability: an accepted request is never lost
- Latency: critical messages under 5 s end to end
- Availability: 99.99% for ingestion
- Delivery: at-least-once with dedupe, so users see it once
- Resilience: survives provider and region failures
- Compliance: GDPR, CAN-SPAM, do-not-disturb rules
Out of scope
Building our own SMTP server or carrier connections, and the marketing UI for designing campaigns. We integrate with providers rather than replace them.
Capacity estimation
Assume 100M monthly active users and about 50M notifications a day across all channels.
| Quantity | Estimate | How |
|---|---|---|
| Average throughput | ~580/s | 50M / 86,400 s |
| Peak ingestion | ~6,000/s (design for 10,000/s) | Peak at 10x, plus headroom |
| Campaign burst | ~2,800/s sustained | 10M messages sent within one hour |
| Notification history | ~4.5 TB hot (90 days) | 50M/day x ~1 KB x 90; older data moves to cold storage |
| Kafka ingress | ~10 MB/s | 10k msgs/s x ~1 KB, replicated 3x |
| Provider limits | Often the real bottleneck | SMS long codes ~1 msg/s each; email accounts have per-second quotas |
Our own infrastructure scales linearly. External providers are the true limit, so the design throttles per provider and never assumes unlimited downstream capacity.
API design
| Endpoint | Purpose | Notes |
|---|---|---|
POST /v1/notifications | Send one notification | Body: recipient (user_id), category, priority, template_id, data, optional channels, send_at, ttl. Header: Idempotency-Key. Returns 202 Accepted with an id. |
POST /v1/notifications:batch | Send many | Up to 1,000 items per call; each item is processed independently. |
GET /v1/notifications/{id} | Status and history | Per-channel state and timestamps. |
POST /v1/campaigns | Create a bulk send | Audience segment plus template; supports pause and cancel. |
PUT /v1/users/{id}/preferences | Opt-in / opt-out | Per category and channel, with quiet hours and time zone. |
POST /v1/devices | Register a push token | Tokens are refreshed and deactivated automatically. |
POST /v1/webhooks/{provider} | Provider callbacks | Signed; feeds the delivery status service. |
The API answers 202, not 200: it promises the request is durably stored and will be processed, not that the message has been delivered. Delivery outcomes come from status queries or webhooks.
High-level architecture
Components
- Notification API: authenticates the caller, validates the payload, checks the idempotency key, applies per-tenant rate limits, and writes the request plus an outbox row in one transaction (in a transactional store such as PostgreSQL, or DynamoDB transactions). A relay then publishes to Kafka, so a crash between "saved" and "queued" cannot lose a request.
- Kafka priority lanes: separate topics and consumer pools for critical, transactional and bulk. A marketing blast can never delay a login code.
- Orchestrator: decides whether, what and where to send: dedupe, opt-outs and frequency caps, quiet hours, template rendering, and channel selection with fallback.
- Per-channel queues and workers: each channel scales and fails independently. Workers use a common provider-adapter interface, with circuit breakers and a secondary provider.
- Delivery status service: consumes worker events and provider receipts and maintains the notification state machine.
- Retry topics and DLQ: failures are retried with backoff, and permanently failing messages are parked for inspection.
Life of a notification
- The producer sends the request with an idempotency key. The API stores it and replies 202 Accepted.
- The outbox relay publishes it to the right priority lane.
- The orchestrator consumes it, checks the dedupe cache and the user's preferences, renders the template in the user's locale, and picks channels.
- The message goes to the channel queue. A worker pulls it, respects the provider rate limit, and calls the provider.
- The worker reports
SENT,RETRYorFAILED. Later, the provider's webhook reportsDELIVEREDorBOUNCED. - The status service updates the record and, if requested, calls the producer's webhook.
Delivery tracking and retries
Retry policy
- Classify errors: retryable (5xx, timeouts, 429) versus permanent (invalid address, unregistered device token, hard bounce).
- Retry retryable errors with exponential backoff and jitter (for example 1 s, 5 s, 30 s, 5 min), through retry topics so a slow message never blocks the main lane.
- Every notification has a TTL. A login code that could not be sent within five minutes is dropped, because a late code is worse than none.
- After the maximum attempts, or on a permanent error, the message moves to
DEAD_LETTERorBOUNCED. Permanent failures also update the suppression list and deactivate bad device tokens, so we stop trying.
Delivery guarantees and idempotency
True exactly-once delivery across an external provider is impossible: if a worker crashes after the provider accepts a message but before we record it, we cannot know. The design therefore aims for at-least-once processing with idempotency at every step, which users experience as exactly-once.
- At the API: the idempotency key (kept for 24 hours) returns the original notification ID on a retry.
- At the orchestrator: a dedupe key (
tenant + notification id, plus a content hash for category-level dedupe) stops reprocessing after a crash or redelivery. Kafka offsets commit only after processing succeeds. - At the worker: the provider's own idempotency token is used where it exists (many email and push APIs support one). The remaining duplicate window is tiny and accepted; for critical messages, content is naturally safe to repeat (an OTP resend).
- At the status service: each transition carries a version, and stale or duplicate events are ignored.
Priority, fan-out and campaigns
- Isolation by lane: critical, transactional and bulk have separate topics, consumer groups and provider quotas. Under pressure, bulk waits; critical does not.
- Streaming fan-out: the audience resolver pages through user IDs, and the chunker publishes small batches, so a 10M-user campaign uses constant memory and can be paused mid-flight.
- Throttling: workers use a token bucket per provider account and tenant, adapting downward on 429 responses and honouring
Retry-After. - Frequency caps and quiet hours: marketing messages respect per-user daily limits and are deferred to the user's local window (stored as an IANA time zone, so daylight saving is handled).
- Scheduling: future sends sit in a time-bucketed store (for example a Redis sorted set or DynamoDB with TTL) and a scheduler releases them into the right lane when due.
Channels and providers
| Channel | Providers | Special handling |
|---|---|---|
| SES, SendGrid (failover) | SPF, DKIM and DMARC; bounce and complaint feedback loops; unsubscribe header; separate IP pools for transactional and marketing. | |
| SMS | Twilio, Vonage (failover) | Per-country routing and sender IDs; carrier throughput limits; DND registries; segment-length cost awareness. |
| Mobile push | FCM, APNs | Device tokens per user; multiple devices; collapse keys; token invalidation on error. |
| Web push | Web Push protocol | Subscription expiry; encryption keys per subscription. |
| In-app | WebSocket gateway + inbox store | Persist first, then push live; offline users read their inbox on next connect. |
Each provider sits behind the same adapter interface (send, parse_receipt, classify_error). Adding or replacing a provider does not touch the orchestrator. A circuit breaker per provider opens on rising error rates and switches traffic to the secondary.
Data model and storage
| Entity | Key | Fields |
|---|---|---|
notifications | notification_id; index by user_id | tenant_id, category, priority, template_id, payload, status, idempotency_key, send_at, ttl, created_at |
deliveries | notification_id + channel | provider, provider_msg_id, attempt, state, error_code, version, updated_at |
preferences | user_id | Per category and channel opt-in, quiet hours, time zone, locale |
devices | user_id + device_id | platform, token, last_seen, active |
templates | template_id + version + locale | Channel-specific body, subject and variables |
suppression | Hashed address | Reason (hard bounce, complaint, unsubscribe), timestamp |
- Intake requests and the outbox live in a transactional store (PostgreSQL, or DynamoDB with transactions) so the request and its outbox row commit atomically; processed rows are archived after a short retention.
- Notification history and deliveries are write-heavy and time-ordered, so they live in a wide-column store (Cassandra or DynamoDB) partitioned by user. The most recent 90 days stay hot; older records move to object storage as Parquet.
- Preferences and devices are read on every send, so they are cached in Redis with event-driven invalidation and backed by a relational database or DynamoDB.
- Payloads may contain personal data: they are encrypted at rest, redacted from logs, and covered by retention rules.
Handling failures and edge cases
Most of the real work in a notification system is what happens when something goes wrong:
| Situation | What happens | How the design handles it |
|---|---|---|
| Producer retries a request | Duplicate submission | Idempotency key returns the original notification ID. |
| API crashes after saving | Saved but not queued | Outbox relay publishes it later; nothing is lost. |
| Kafka broker fails | Partition leader lost | Replication factor 3 with in-sync replicas; producers retry; consumers rebalance. |
| Orchestrator crashes mid-message | Message half-processed | Offset not committed, so it is reprocessed; dedupe makes it safe. |
| Poison message | Bad template or data breaks processing | Bounded retries, then DLQ. It never blocks the partition. |
| Provider outage or 5xx | Sends failing | Circuit breaker opens, traffic fails over to the secondary provider, queue absorbs backlog; TTL drops stale messages. |
| Provider throttling (429) | Rate limit hit | Token bucket slows down and honours Retry-After; queue depth rises safely. |
| Invalid token or address | Permanent failure | No retries; token deactivated or address suppressed. |
| Receipts arrive twice or out of order | State could go backwards | Versioned state machine ignores stale events. |
| User opts out while queued | Message already in flight | Preferences are re-checked at send time for marketing traffic. |
| User has several devices | Fan-out to many tokens | One delivery record per device; collapse keys avoid duplicates on the same device. |
| Runaway producer or bug loop | Notification storm | Per-tenant and per-user rate limits, frequency caps, and a tenant-level kill switch. |
| Bulk blast during an OTP spike | Resource contention | Separate lanes, consumer pools and provider quotas; critical always wins. |
| Region outage | One region lost | Traffic moves to another region; queues replicate across regions; dedupe absorbs replayed messages. |
| Late delivery | Message no longer relevant | Per-notification TTL; expired messages are dropped and marked EXPIRED. |
| Time zones and daylight saving | Wrong local send time | IANA time zones and scheduling in UTC computed from the user's zone. |
Security and compliance
- Authentication and tenancy: API keys or OAuth per tenant, quotas per tenant, and strict isolation of data and rate limits.
- Consent and opt-out: unsubscribe links and headers, a global suppression list, and recorded consent for marketing (GDPR, CAN-SPAM, TCPA, local DND rules).
- Webhook security: verify provider signatures; sign our outgoing webhooks; replay-protect with timestamps.
- Abuse controls: block use of the service to spam; template variable escaping to prevent injection into email and HTML.
- Data protection: encrypt payloads, minimise stored personal data, redact logs, and support deletion requests.
- Auditability: an immutable audit trail of who sent what to whom and when.
Observability and SLOs
- SLOs: ingestion availability 99.99% with p99 under 100 ms; critical lane from accepted to provider hand-off p99 under 5 s; transactional under 60 s; bulk within the campaign window.
- Lane health: consumer lag and "time in queue" per lane, the first signal that something is backing up.
- Provider health: success rate, latency, throttling and bounce rate per provider and country, driving circuit breakers and alerts.
- Delivery funnel: accepted, sent, delivered, opened, per channel and template.
- Tracing: one
notification_idfollows a message across every service and log line. - Synthetic canaries: a test notification on each channel and provider every minute, alerting on missing delivery.
Trade-offs and alternatives
| Decision | Chosen | Alternative | Why |
|---|---|---|---|
| Queue technology | Kafka | SQS / RabbitMQ | High throughput, replay and ordering per key. A managed queue is simpler if scale is smaller. |
| Delivery semantics | At-least-once + idempotency | Exactly-once | True exactly-once is not possible across an external provider. |
| Pipeline shape | Shared orchestrator, per-channel workers | One service per channel | Preferences, dedupe and templating live once; channels still scale and fail independently. |
| API style | Async 202 + status | Synchronous send | Protects callers from provider latency and outages. |
| Providers | Two per channel behind an adapter | Single vendor | Removes a single point of failure at the cost of extra integration work. |
| Scheduling | Time-bucketed store + scheduler | Long broker delays | Brokers handle long delays poorly; buckets scale and are easy to cancel. |
Possible next steps
- Per-user send-time optimisation and smart digests that batch low-priority messages.
- A/B testing of templates and channels, with delivery and engagement analytics.
- Channel fallback rules (push, then SMS, then email) for critical messages.
- Additional channels such as WhatsApp and voice through the same adapter interface.