In short
A notification system turns application events into messages: it accepts an event, works out who should hear about it, checks each recipient’s preferences, quiet hours and rate limits, renders a template, and puts one job per recipient and channel on that channel’s queue. Workers hand jobs to provider adapters for push, email and SMS, and the providers’ status callbacks update a delivery record. A dedupe key per event, recipient and channel keeps retries from sending twice, and suppression lists for invalid tokens, bounces, complaints and opt-outs keep the system allowed to send at all.
Key points
- Model one notification per recipient per channel, each with a dedupe key under a unique index; queues and providers deliver at least once, so the dedupe has to live in your database.
- Resolve preferences in a fixed order: suppressions and legal opt-outs first, then the user’s category and channel choices, then defaults.
- Quiet hours belong to the recipient’s IANA time zone, not the server’s; defer non-urgent pushes and texts to the end of the window, and let a short list of critical categories through.
- Give each channel its own queue, workers, retry policy and rate limits, so a slow SMS provider never delays a password reset email.
- Delete push tokens that APNs or FCM report as invalid, suppress hard bounces and complaints at once, and support one-click unsubscribe for marketing email.
- Batch low-priority events into digests, and watch opt-out rates by category: they are the clearest signal that you are sending too much.
Requirements
Sending one email is an API call. A notification system is everything around that call that keeps users from being spammed, woken up, double-notified or silently dropped. Write the requirements down before choosing parts; most of them become a table or a check in the pipeline.
| Requirement | What it means in practice |
|---|---|
| Multiple channels | Push, email and SMS at least, often in-app and chat too, behind one internal API |
| User preferences | Per category and per channel: a user can keep security email and turn off marketing push |
| Templates | Versioned, per channel and per locale, rendered from event data; push and SMS need short variants |
| Scheduling | Send now, send at a time, or send at the end of quiet hours |
| Quiet hours | Measured in the recipient’s time zone, with an explicit list of what may break through |
| Rate limits per user | A cap per user, channel and window, so a burst of events does not become a burst of buzzes |
| Deduplication | A retried event or a double-clicked button produces one notification, not two |
| Delivery tracking | Every attempt recorded, with the provider’s message id and the final status from its callbacks |
| Unsubscribe and suppression | Opt-outs, bounced addresses, complaints and dead push tokens are honored on the next send |
Priorities shape every later decision. A useful split is critical (security alerts, sign-in codes), transactional (a receipt, a booking change), social (a reply, a mention) and marketing. Critical messages skip quiet hours and digests; marketing messages are the first to be rate-limited and the only ones that need a list-style unsubscribe.
Architecture: from event to delivered message
- A service emits an event, such as
comment.created, ideally through an outbox written in the same transaction as the change, so the event is never lost and never sent for a change that rolled back. - The notification service accepts it, records it under its event id, and resolves recipients: the post’s author, the thread’s followers, a team.
- For each recipient, preference resolution decides which channels are allowed, and scheduling applies quiet hours, digests and per-user rate limits.
- Template resolution renders the message for each channel in the recipient’s locale, and the service writes one notification row per recipient and channel, keyed by a dedupe key.
- Each row becomes a job on that channel’s queue. Channel workers call a provider adapter: APNs or FCM for push, an email service, an SMS gateway.
- Providers report back through status callbacks: delivered, bounced, complained, failed, token invalid. A callback handler updates the delivery record and the suppression list.
Separate queues per channel are the main structural decision. Channels fail independently and have different speeds, limits and retry rules: an email provider that slows down must not delay sign-in codes going out by push. Within a channel, a separate high-priority queue, or at least separate workers, keeps critical messages from waiting behind a marketing send. The queueing basics are in background jobs and queues.
Provider callbacks are inbound webhooks: verify their signatures, accept them quickly, process them from a queue, and expect duplicates and out-of-order arrivals, such as a “delivered” after a “bounced”. The sending side of the same problem is in webhook delivery system design. If your app also shows notifications in real time while it is open, that path is a socket or an event stream rather than a provider; see WebSockets vs server-sent events.
The data model
| Table | Key columns | Notes |
|---|---|---|
events | event id, type, payload, created at | One row per accepted event; the event id is the producer’s, so a resent event is recognized |
notifications | id, event id, user id, channel, category, priority, template version, status, not before, dedupe key | One row per recipient per channel; a unique index on the dedupe key |
delivery_attempts | notification id, attempt number, provider, provider message id, response code, latency, time | Append-only; the history support staff read when a user says they got nothing |
preferences | user id, category, channel, enabled, digest mode | Absent rows fall back to defaults per category |
user_settings | user id, IANA time zone, locale, quiet hours start and end | Store a zone name such as Europe/Berlin, never a fixed offset, so daylight saving time is handled |
devices | user id, platform, push token, app version, last registered at | One user has many devices; refresh the token on every app start |
templates | key, channel, locale, version, subject, body | Versioned, so a notification records exactly what was sent |
suppressions | channel, address or token, reason, time | Hard bounces, complaints, unsubscribes, SMS opt-outs and dead tokens; checked before every send |
digest_items | user id, digest key, notification id, created at | Low-priority items waiting to be batched into one message |
The notifications row is the unit everything else refers to. Its status moves through a small state machine, such as pending, deferred, queued, sent, delivered, failed and suppressed, and every transition is written with the reason. When someone asks why they never got an email, the answer is one query.
Preferences and quiet hours
Resolve a send in a fixed order. Suppressions win over everything, since a bounced address or a dead token cannot be fixed by sending again, and an opted-out SMS number must not be texted. Then the user’s choice for this category and channel. Then the category’s default. Then timing: quiet hours, digests and rate limits. Some categories, such as security alerts and sign-in codes, should not be switchable off at all; say so in the settings screen rather than hiding them.
Quiet hours are local time for the recipient, so compute them from the user’s IANA zone with a time-zone library, not from the server clock or a stored offset. The window usually crosses midnight, and the end time has to be recomputed per day because of daylight saving time. The check below defers non-critical pushes and texts to the end of the window and lets email through, since email does not wake anyone; that last rule is a product choice.
from dataclasses import dataclass
from datetime import datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
@dataclass
class Prefs:
tz: str # IANA zone, e.g. "Asia/Kolkata"
allowed: dict # category -> set of channels the user allows
quiet_start: time = time(22, 0)
quiet_end: time = time(8, 0)
def quiet_until(now_utc: datetime, p: Prefs):
"""UTC time quiet hours end, or None if it is not quiet for this user now."""
local = now_utc.astimezone(ZoneInfo(p.tz))
t, s, e = local.time(), p.quiet_start, p.quiet_end
quiet = (t >= s or t < e) if s > e else (s <= t < e) # window may cross midnight
if not quiet:
return None
day = local.date() if t < e else local.date() + timedelta(days=1)
return datetime.combine(day, e, tzinfo=ZoneInfo(p.tz)).astimezone(timezone.utc)
def decide(category: str, channel: str, critical: bool, now_utc: datetime, p: Prefs):
if channel not in p.allowed.get(category, set()):
return ("skip", "user opted out")
wake = None if (critical or channel == "email") else quiet_until(now_utc, p)
return ("defer", wake) if wake else ("send", now_utc)Run against a user in America/New_York, a push at 03:30 UTC on 5 November 2026, which is 22:30 local, is deferred to 13:00 UTC, which is 08:00 local after the clocks went back; at 05:00 UTC on 8 March 2026, the night daylight saving time starts there, it is deferred to 12:00 UTC, again 08:00 local. A fixed offset would get one of the two wrong. Where you do not know a user’s zone yet, take it from the device or browser at sign-up and let the user change it.
Idempotency and deduplication
Every hop in the pipeline delivers at least once. The producer may resend an event after a timeout, the queue may redeliver a job whose worker crashed, and a worker may call a provider that accepted the message but timed out before saying so. Exactly-once sending does not exist; what you can build is effectively-once by deduplicating at each step, as described in idempotency and job queues.
- At intake: the producer’s event id, stored with a unique constraint, so a resent event is ignored.
- At fan-out: a dedupe key derived from the event id, the recipient and the channel, under a unique index on
notifications. A second insert fails, and the worker moves on. - At the provider: pass your notification id as the provider’s idempotency key or message id where it accepts one, so a retry after a timeout is recognized on their side too.
- At the content level: several events that mean the same thing to a user, such as five likes in a minute, should become one notification. APNs supports this on the device with the
apns-collapse-idheader, which merges notifications with the same id into one.
import hashlib
# Uses Prefs, decide, datetime and timezone from the block above.
def dedupe_key(event_id: str, user_id: str, channel: str) -> str:
return hashlib.sha256(f"{event_id}|{user_id}|{channel}".encode()).hexdigest()[:24]
queued = {} # stands in for a table with a UNIQUE index on dedupe_key
def fan_out(event: dict, user_id: str, p: Prefs, now_utc: datetime) -> list:
out = []
for channel in ("push", "email", "sms"):
verdict, when = decide(event["category"], channel, event["critical"], now_utc, p)
key = dedupe_key(event["id"], user_id, channel)
if verdict == "skip" or key in queued:
continue # opted out, or a redelivered event already queued here
queued[key] = (channel, when.strftime("%Y-%m-%d %H:%M UTC"))
out.append(queued[key])
return out
p = Prefs("Asia/Kolkata", {"comments": {"push", "email"}, "security": {"push", "email", "sms"}})
now = datetime(2026, 11, 4, 18, 30, tzinfo=timezone.utc) # midnight in Kolkata
reply = {"id": "evt_1", "category": "comments", "critical": False}
print(fan_out(reply, "u_42", p, now))
print(fan_out(reply, "u_42", p, now)) # the same event delivered twice
print(fan_out({"id": "evt_2", "category": "security", "critical": True}, "u_42", p, now))
# [('push', '2026-11-05 02:30 UTC'), ('email', '2026-11-04 18:30 UTC')] push waits for 08:00 local
# [] then push, email and sms all at 2026-11-04 18:30 UTC: critical skips quiet hoursRetries, expiry and provider failover
Classify every provider response before deciding what to do with it. Temporary errors, such as timeouts, 5xx responses and 429 rate limits, retry with capped exponential backoff and jitter, honoring any Retry-After the provider sends. Permanent errors, such as an invalid token, a hard bounce or an invalid number, never retry: they update the suppression list. Apple’s documentation, for example, lists 410 Unregistered and ExpiredToken for tokens that are no longer active, and says 5xx responses can be retried after a wait.
Give each notification an expiry. A sign-in code is useless after it expires, and a “your driver is outside” push delivered tomorrow is worse than none. APNs has the idea built in: the apns-expiration header tells it how long to keep trying, and Apple says it stores a notification for 30 days or less, depending on that date. Your own queue should drop expired jobs instead of sending them late.
Failover to a second email or SMS provider protects against an outage and creates a new duplicate risk: if the first provider timed out, it may still have sent the message. Fail over on errors that prove nothing was sent, such as connection refused or an explicit rejection, and on a circuit breaker that opens when a provider’s error rate climbs; for timeouts, either accept the rare duplicate for critical messages or wait for the first provider’s callback. Keep templates and sender identities set up on both providers ahead of time, and send real traffic through the secondary regularly so you know it works.
Rate limits and digests
Three kinds of limit apply. Per user, per channel and window, such as a cap on pushes per hour, protects the user and your opt-out rate. Per provider, the throughput your account is allowed, which the channel workers must respect or they will see 429s. Global, a ceiling that stops a bug or a bad campaign from sending to everyone at once. A token bucket per key covers all three; the algorithms and their trade-offs are in rate limiter system design.
What happens when a limit is hit depends on the priority. Critical messages are never dropped by a user cap. Social and marketing messages are deferred into a digest or dropped, and the reason is recorded on the notification row. A digest collects digest_items under a key, such as daily activity for one user, and a scheduled job renders them into one message at a chosen local time, using the same time-zone logic as quiet hours.
| Priority | Quiet hours | User rate limit | Digest | Examples |
|---|---|---|---|---|
| Critical | Ignored | Not applied | Never | Security alerts, sign-in codes |
| Transactional | Applied to push and SMS | Generous | Rarely | Receipts, booking changes, shipping updates |
| Social | Applied | Applied | When volume is high | Replies, mentions, likes, follows |
| Marketing | Applied | Strict | Often | Product news, offers, re-engagement |
The product side of this, what deserves a push at all and how to ask for permission, is in push notification best practices.
Push, email and SMS specifics
Push. Device tokens change, and keeping them current is most of the work. Apple tells developers never to cache device tokens in local storage on the device, because APNs issues a new token when a user restores from a backup, installs the app on a new device or reinstalls the operating system; ask for the token on every launch and send it to your server. When APNs answers 410, its response includes a timestamp at which it confirmed the token was no longer valid; compare it with the token’s last registration time before deleting, in case the device registered again. FCM is moving its per-device targeting from registration tokens to Firebase Installation IDs, with both supported during the transition, and the same rules apply to either: store each registration with a timestamp and update it whenever the app reports it, treat it as stale after a month without the app connecting (FCM’s default), and remove it when sends fail with UNREGISTERED (HTTP 404), or with INVALID_ARGUMENT (HTTP 400) for a payload you know is valid. On Android, FCM treats a registration inactive for 270 days as expired and rejects sends to it.
Email. Handle bounces and complaints from your provider’s callbacks: suppress hard bounces at once, retry soft bounces a few times before suppressing, and suppress anyone who marks a message as spam. Mailbox providers now enforce rules for bulk senders. Google’s sender guidelines require every sender to set up SPF or DKIM, valid forward and reverse DNS, and TLS, and to keep the spam rate in Postmaster Tools below 0.3 percent. Senders of close to 5,000 or more messages a day to personal Gmail accounts must also set up SPF, DKIM and DMARC with alignment, and give marketing and subscribed messages one-click unsubscribe plus a visible unsubscribe link; transactional messages such as password resets are excluded. Google recommends honoring unsubscribe requests within 48 hours, aiming for a spam rate below 0.1 percent, and says that from November 2025 Gmail is ramping up enforcement, including temporary and permanent rejections of non-compliant mail. Yahoo’s requirements are similar: SPF, DKIM and a DMARC policy, one-click unsubscribe, unsubscribes honored within 2 days, and a spam rate below 0.3 percent.
One-click unsubscribe is RFC 8058. The message carries an HTTPS unsubscribe URL and a second header that marks it as one-click, both covered by the DKIM signature. The mail client sends a POST to the URL with no cookies or login; the URL must carry an opaque, hard-to-forge identifier, and the endpoint must not answer with a redirect:
List-Unsubscribe: <https://example.com/unsubscribe/opaque-token-for-this-recipient>, <mailto:unsubscribe@example.com?subject=unsubscribe>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
# The client then sends:
POST /unsubscribe/opaque-token-for-this-recipient
Content-Type: application/x-www-form-urlencoded
List-Unsubscribe=One-ClickSMS. Consent rules for texts differ by country, and they are stricter than for email in many places. Generally, marketing texts need the recipient’s prior consent, every message must identify the sender, and opt-out replies must be honored promptly. In the US, for example, the FCC’s 2024 order on the Telephone Consumer Protection Act says consumers may revoke consent by any reasonable means, treats replies such as stop, quit, end, revoke, opt out, cancel or unsubscribe as revocation, requires senders to honor it within a reasonable time of no more than 10 business days, and allows one confirmation text. The UK, EU countries, Canada, India, Australia and others each have their own rules, some with sender registration or content templates. Store consent per number with its source and time, process opt-out keywords from inbound messages into the suppression list, and check the rules for every country you send to. This is not legal advice.
Monitoring
- Queue depth and age of the oldest job, per channel and priority. Age is the number users feel: a sign-in code that waits is a failed sign-in.
- Time from event to provider acceptance, and from acceptance to the delivered callback where the channel reports it.
- Provider responses by code, per provider. A rise in 429s means you are over your limit; a rise in invalid-token responses after a release can mean token registration broke.
- Bounce rate and complaint rate for email, alongside the spam rate Gmail reports in Postmaster Tools.
- Opt-out and unsubscribe rate per category. The clearest sign of too many notifications, and often the first one.
- Dedupe hits, deferred jobs and suppressed sends, so you can tell “we chose not to send” from “we failed to send”.
Alert on the oldest job’s age in the critical queues and on provider error rates, not on volume. And give support a screen over notifications and delivery_attempts for one user: most notification bugs arrive as “I never got it”.
BYD Tech builds web apps and their backends: the code lives in the client’s GitHub from day one, the founders are on the calls, and AI-assisted work is reviewed by an engineer.
Sources
Checked when this post was written.
- Apple Developer docs: Handling notification responses from APNs
- Apple Developer docs: Registering your app with APNs
- Apple Developer docs: Sending notification requests to APNs
- Firebase docs: Best practices for FCM registration management
- RFC 8058: Signaling One-Click Functionality for List Email Headers
- Google Workspace Admin Help: Email sender guidelines
- Google Workspace Admin Help: Email sender guidelines FAQ
- Yahoo Sender Hub: Sender best practices
- FCC 24-24: Report and Order on revoking consent under the Telephone Consumer Protection Act (2024)