← All case studies
System design teardown

Design a notification service.

How to build a platform that delivers email, SMS, push and in-app messages reliably: never losing a request, never double-sending, and keeping urgent messages fast during a campaign.

Reference design, not a client projectHigh-level designMulti-channel~10k notifications/s design point~10 min read

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.

QuantityEstimateHow
Average throughput~580/s50M / 86,400 s
Peak ingestion~6,000/s (design for 10,000/s)Peak at 10x, plus headroom
Campaign burst~2,800/s sustained10M 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/s10k msgs/s x ~1 KB, replicated 3x
Provider limitsOften the real bottleneckSMS 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

EndpointPurposeNotes
POST /v1/notificationsSend one notificationBody: 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:batchSend manyUp to 1,000 items per call; each item is processed independently.
GET /v1/notifications/{id}Status and historyPer-channel state and timestamps.
POST /v1/campaignsCreate a bulk sendAudience segment plus template; supports pause and cancel.
PUT /v1/users/{id}/preferencesOpt-in / opt-outPer category and channel, with quiet hours and time zone.
POST /v1/devicesRegister a push tokenTokens are refreshed and deactivated automatically.
POST /v1/webhooks/{provider}Provider callbacksSigned; 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

Product servicesorders · auth · billingCampaign toolmarketing · bulkSchedulerdelayed · recurringNotification API · statelessauthN/Z · schema validation · idempotency key · per-tenant rate limit · outbox writeKafka · priority lanes (isolated consumer groups)criticalOTP · security alertstransactionalreceipts · status updatesbulkmarketing · digestspublishOrchestrator · stateless consumersdedupe · opt-out & preferences · quiet hourstemplate render · channel selection & fallbackIdempotency & dedupe cacheRedis · 24h TTLPreference & device storeopt-outs · tokens · localeTemplate serviceversioned · localisedEmail queueEmail workersSES · SendGrid failoverpullSES / SendGridSMS queueSMS workersTwilio · Vonage failoverTwilio / VonagePush queuePush workersFCM · APNsFCM / APNsIn-app queueIn-app gatewayWebSocket · inboxDevice / browserDelivery status servicestate machine · receipts · webhooksreceiptsStatus store + analyticsRetry topics + DLQfailedLEGENDCompute (stateless)Data storeQueue / topicExternal provider
Figure 1. Component view. Requests enter through a thin API, split into priority lanes, pass through the orchestrator, and fan out into isolated per-channel pipelines. Failures loop into retry topics and a dead-letter queue instead of blocking anything.

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

ProducercallerNotif APIKafkalaneOrchestratorChannel workerProviderSES/Twilio/FCMStatus svcPOST /v1/notifications + Idempotency-Keyvalidate · outbox commit202 Accepted {id}publish to priority laneconsumededupe · prefs · renderenqueue to channel queuesend()accepted / 429 / 5xxSENT · RETRY · FAILEDdelivery receipt (webhook)optional callback: delivered
Figure 2. The happy path and its feedback loops. The caller gets a fast 202; everything after that is asynchronous.
  1. The producer sends the request with an idempotency key. The API stores it and replies 202 Accepted.
  2. The outbox relay publishes it to the right priority lane.
  3. The orchestrator consumes it, checks the dedupe cache and the user's preferences, renders the template in the user's locale, and picks channels.
  4. The message goes to the channel queue. A worker pulls it, respects the provider rate limit, and calls the provider.
  5. The worker reports SENT, RETRY or FAILED. Later, the provider's webhook reports DELIVERED or BOUNCED.
  6. The status service updates the record and, if requested, calls the producer's webhook.

Delivery tracking and retries

ACCEPTEDQUEUEDPROCESSINGSENDINGSENTDELIVEREDSCHEDULEDsend_at in futuredueSUPPRESSEDopt-out · dedupe · quiet hrsRETRY_WAIT5xx · 429 · timeoutbackoff + jitterDEAD_LETTERattempts exhaustedBOUNCEDpermanent failureDashed = terminal states.Each transition is an idempotent,versioned write: a late or duplicateevent never moves a state backwards.
Figure 3. Notification state machine. Transitions are versioned, so out-of-order or duplicate events cannot corrupt state.

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_LETTER or BOUNCED. 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

Campaignsegment · 10M usersAudience resolverstreams user-ID pagesChunker1,000-user batchesBulk laneKafka · low priorityThrottled workerstoken bucket / providerProvidersSES · Twilio · FCMProgress trackerbatches done / total · per-batch retryKill switch / pausestops chunker + workers within secondsBulk never shares capacity with thecritical lane, so OTPs are unaffected.
Figure 4. A campaign is streamed and chunked rather than loaded into memory, and it runs on the bulk lane only.
  • 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

ChannelProvidersSpecial handling
EmailSES, SendGrid (failover)SPF, DKIM and DMARC; bounce and complaint feedback loops; unsubscribe header; separate IP pools for transactional and marketing.
SMSTwilio, Vonage (failover)Per-country routing and sender IDs; carrier throughput limits; DND registries; segment-length cost awareness.
Mobile pushFCM, APNsDevice tokens per user; multiple devices; collapse keys; token invalidation on error.
Web pushWeb Push protocolSubscription expiry; encryption keys per subscription.
In-appWebSocket gateway + inbox storePersist 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

EntityKeyFields
notificationsnotification_id; index by user_idtenant_id, category, priority, template_id, payload, status, idempotency_key, send_at, ttl, created_at
deliveriesnotification_id + channelprovider, provider_msg_id, attempt, state, error_code, version, updated_at
preferencesuser_idPer category and channel opt-in, quiet hours, time zone, locale
devicesuser_id + device_idplatform, token, last_seen, active
templatestemplate_id + version + localeChannel-specific body, subject and variables
suppressionHashed addressReason (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:

SituationWhat happensHow the design handles it
Producer retries a requestDuplicate submissionIdempotency key returns the original notification ID.
API crashes after savingSaved but not queuedOutbox relay publishes it later; nothing is lost.
Kafka broker failsPartition leader lostReplication factor 3 with in-sync replicas; producers retry; consumers rebalance.
Orchestrator crashes mid-messageMessage half-processedOffset not committed, so it is reprocessed; dedupe makes it safe.
Poison messageBad template or data breaks processingBounded retries, then DLQ. It never blocks the partition.
Provider outage or 5xxSends failingCircuit breaker opens, traffic fails over to the secondary provider, queue absorbs backlog; TTL drops stale messages.
Provider throttling (429)Rate limit hitToken bucket slows down and honours Retry-After; queue depth rises safely.
Invalid token or addressPermanent failureNo retries; token deactivated or address suppressed.
Receipts arrive twice or out of orderState could go backwardsVersioned state machine ignores stale events.
User opts out while queuedMessage already in flightPreferences are re-checked at send time for marketing traffic.
User has several devicesFan-out to many tokensOne delivery record per device; collapse keys avoid duplicates on the same device.
Runaway producer or bug loopNotification stormPer-tenant and per-user rate limits, frequency caps, and a tenant-level kill switch.
Bulk blast during an OTP spikeResource contentionSeparate lanes, consumer pools and provider quotas; critical always wins.
Region outageOne region lostTraffic moves to another region; queues replicate across regions; dedupe absorbs replayed messages.
Late deliveryMessage no longer relevantPer-notification TTL; expired messages are dropped and marked EXPIRED.
Time zones and daylight savingWrong local send timeIANA 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_id follows 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

DecisionChosenAlternativeWhy
Queue technologyKafkaSQS / RabbitMQHigh throughput, replay and ordering per key. A managed queue is simpler if scale is smaller.
Delivery semanticsAt-least-once + idempotencyExactly-onceTrue exactly-once is not possible across an external provider.
Pipeline shapeShared orchestrator, per-channel workersOne service per channelPreferences, dedupe and templating live once; channels still scale and fail independently.
API styleAsync 202 + statusSynchronous sendProtects callers from provider latency and outages.
ProvidersTwo per channel behind an adapterSingle vendorRemoves a single point of failure at the cost of extra integration work.
SchedulingTime-bucketed store + schedulerLong broker delaysBrokers 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.

Facing a similar problem?

Tell us what's going on and we'll get back to you within one business day.