System DesignIntermediate 16 min Lesson 39 of 42

Design a Notification System

Send email, SMS and push reliably: templates, preferences, deduplication, rate limits, retries and delivery tracking.

System Design · Lesson 39 of 42
0/42 done(0%)

What is it? #

A notification system delivers messages to users across several channels — email, SMS, push and in-app — reliably and without annoying them.

The technical challenge is delivery: third-party providers fail, rate limits apply, and nothing may be lost or duplicated.

The product challenge is restraint. A system that sends too much gets muted or reported as spam, which destroys deliverability for the messages that matter.

A good design separates what happened (an event), who should hear about it (preferences), how it is phrased (templates), and how it is delivered (channel adapters).

Think of it like this #

A post room that receives internal memos and decides how each recipient prefers to be contacted — email, text, or a note on their desk — and keeps a record of what was sent.

If it forwarded every memo to everyone by every method, people would stop reading any of it.

Simple example #

An e-commerce platform sends order confirmations, shipping updates, promotions and security alerts. Each has different urgency, different channels and different opt-out rules.

Code #

TEXT
1. Architecture

  services ──event──▶ notification service
                          │
                          ├── 1. resolve recipients
                          ├── 2. apply preferences and quiet hours
                          ├── 3. deduplicate
                          ├── 4. render template
                          └── 5. enqueue per channel
                                     │
              ┌──────────────┬───────┴───────┬───────────────┐
              ▼              ▼               ▼               ▼
          email worker   sms worker     push worker    in-app writer
              │              │               │
          provider       provider        APNs / FCM
              │              │               │
              └── delivery webhooks ─────────┘ → status tracking
PYTHON
# 2. From event to delivery
URGENCY = {"security_alert": "critical", "order_shipped": "normal",
           "promotion": "low"}


def notify(event_type: str, user_id: int, context: dict, dedupe_key: str):
    if dedupe.seen(dedupe_key, ttl=3600):           # same event, same user
        return

    user = users.get(user_id)
    urgency = URGENCY[event_type]
    channels = preferences.channels_for(user, event_type)   # user's choices

    if urgency != "critical":
        channels = [c for c in channels if not quiet_hours(user, c)]
        if throttle.exceeded(user_id, event_type):          # per-user frequency cap
            return

    for channel in channels:
        rendered = templates.render(event_type, channel, user.locale, context)
        queues[channel].enqueue({
            "user_id": user_id,
            "dedupe_key": dedupe_key,
            "subject": rendered.subject,
            "body": rendered.body,
            "urgency": urgency,
        })
    dedupe.record(dedupe_key)


# 3. A channel worker: retry transient failures, never retry hard bounces
def email_worker(job):
    try:
        result = email_provider.send(job, timeout=10)
        deliveries.record(job["dedupe_key"], "email", "sent", result.provider_id)
    except TransientError:
        retry_with_backoff(job, max_attempts=5)
    except PermanentError as exc:                   # invalid address, unsubscribed
        deliveries.record(job["dedupe_key"], "email", "failed", str(exc))
        suppression.add(job["user_id"], "email", reason=str(exc))
TEXT
4. Data model

templates       event_type, channel, locale, subject, body  — versioned
preferences     user_id, event_type, channel, enabled, quiet_hours
deliveries      id, user_id, event_type, channel, status, provider_id, timestamps
suppressions    user_id, channel, reason   — hard bounces and unsubscribes

Status flow: queued → sent → delivered → opened / clicked
             or     → failed → suppressed
TEXT
5. Rules that keep you deliverable

honour unsubscribe immediately     legally required in most jurisdictions
suppress hard bounces permanently  repeated sends to dead addresses hurt reputation
cap frequency per user             a daily digest instead of twenty emails
separate critical from marketing   security alerts must never be opt-out
warm up new sending domains        volume ramped gradually
monitor bounce and complaint rates a spike means stop and investigate

How it works #

Deduplication comes first, because the same event often arrives twice — a retried job, a duplicate webhook, two services publishing the same fact. A dedupe key derived from the event and recipient prevents double sends.

Preferences and quiet hours are applied before rendering, so you do no work for a channel that will not be used. Critical notifications bypass both, which is why security alerts still arrive at 3am.

Rendering is per channel and per locale. The same event needs a short push message and a fuller email, and templates are versioned so a change can be rolled back.

Each channel has its own queue and workers, so a slow SMS provider does not delay emails, and each can be scaled and rate-limited independently.

The distinction between transient and permanent failures is critical. A timeout should be retried; an invalid address should not, and the address should be suppressed so future sends are skipped.

Delivery webhooks from providers update the status. Without them you know what you attempted, not what arrived, and cannot detect a provider quietly dropping messages.

Suppression lists protect sender reputation. Repeatedly sending to dead addresses is one of the fastest ways to end up in spam folders for everyone.

Real-world use #

Most teams use providers — SendGrid, SES, Twilio, APNs and FCM — rather than sending directly, so the system is mostly orchestration, preferences and tracking.

Preference management is a product surface in its own right, and getting it wrong is visible: users who cannot turn off marketing emails unsubscribe from everything, including the transactional messages they wanted.

Digests are the standard answer to volume. Instead of twenty notifications, one summary, which users prefer and which costs less.

Push notifications have their own lifecycle: device tokens expire, users reinstall, and platforms return feedback that must be processed or you accumulate dead tokens.

Testing is awkward and worth investing in. A sandbox channel that records instead of sending lets you test flows without sending real messages to real people, which is a mistake every team makes exactly once.

Common mistakes #

  • No deduplication, so a retried event sends the same message twice.
  • Retrying permanent failures such as invalid addresses, damaging sender reputation.
  • One queue for all channels, so a slow provider blocks everything.
  • Allowing critical alerts to be silenced by marketing preferences, or vice versa.
  • No delivery tracking, so you cannot tell whether messages arrive.

Practice #

Design the notification flow for a booking system: confirmation, reminder 24 hours before, cancellation and a monthly newsletter. For each, specify urgency, channels, whether it is opt-out, the dedupe key and the retry policy. Then describe how a user sets quiet hours without missing a cancellation.

Quick quiz

  1. 1. Why deduplicate before sending?

  2. 2. How should a permanent failure such as an invalid address be handled?

  3. 3. Why give each channel its own queue?

  4. 4. Which notifications should bypass preferences and quiet hours?

  5. 5. Why process delivery webhooks from providers?

Summary

  • Separate events, preferences, templates and channel delivery.
  • Deduplicate first and respect preferences and quiet hours.
  • Give each channel its own queue, retries and rate limits.
  • Retry transient failures only; suppress permanent ones.
  • Track delivery status and cap frequency to stay deliverable.