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 #
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
# 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))
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
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.