Notification System Design: Architecture for Push, Email and SMS

Design a notification system for push, email and SMS: one API, preferences and quiet hours, deduplication, per-channel queues, retries and delivery tracking.

· Founder & developer

Published

Notification System Design: Architecture for Push, Email and SMS

Notifications start as one line of code: when an order ships, send an email. A year later the same company sends push, email and SMS from a dozen services, users get the same alert three times, nobody can say whether a password reset email was delivered, and marketing messages wake people at 3 a.m.

A notification system fixes that by putting one service between the code that decides something happened and the providers that deliver messages. This post designs it: the API, the checks that run before anything is sent, per-channel delivery with retries, and tracking. The diagram near the end shows the whole flow.

Requirements

  • Product services send a notification with one call, without knowing which channels the user has enabled.
  • Push (iOS and Android), email and SMS, with templates per language.
  • Users control channels and topics, and quiet hours are respected.
  • The same event never produces the same message twice.
  • Every message has a status: sent, delivered, bounced, failed.
  • One slow provider never delays the other channels.

The API: events, not messages

Product services should not build emails. They send an event with the facts and an idempotency key:

POST /notifications
{
  "idempotencyKey": "order-8812-shipped",
  "userId": "u_7",
  "type": "order_shipped",
  "data": {"orderId": "8812", "carrier": "UPS", "eta": "2026-10-30"}
}

The notification service owns everything else: which channels this type uses, which template, which language, and whether the user wants it at all. When a team wants to change the wording or add SMS to a notification type, nothing changes in the order service.

Before anything is sent

Four checks run in the API, in this order, before work reaches a channel:

  1. Deduplicate. Store the idempotency key with a TTL and drop repeats. Retries in upstream services are normal, and they should not reach users as duplicates.
  2. Check preferences. Look up the user's channels, topic opt-outs and quiet hours. Security alerts such as password resets usually bypass marketing opt-outs; marketing never does. Write that rule down.
  3. Cap volume. Limit messages per user per channel per hour. A bug that fires 500 events should not become 500 push notifications.
  4. Render. Pick the template for the notification type, channel and the user's language, and fill it with the event data.

Quiet hours need the user's time zone. Hold the message and schedule it for the end of the window instead of dropping it, unless it is time-sensitive.

One queue and one worker pool per channel

After the checks, the API writes one job per channel to that channel's queue. Separate queues are the most important decision in the design: when the SMS provider slows down, SMS jobs pile up in their own queue while push and email keep flowing.

Channel workers call the providers and handle their quirks:

  • Push. iOS devices go through the Apple Push Notification service and Android through the FCM HTTP v1 API. Device tokens go stale: APNs answers 410 for a token that is no longer active and FCM returns an unregistered error. Delete those tokens, or you keep paying latency for messages nobody gets.
  • Email. Send through a provider and report bounces and complaints back. If you send bulk email, add one-click unsubscribe headers as defined in RFC 8058. Gmail's sender guidelines require one-click unsubscribe for marketing and subscribed messages from senders of more than 5,000 messages a day.
  • SMS. The most expensive channel per message and the most regulated. Keep it for codes and urgent alerts, and check the rules for each country you send to (this post is not legal advice).

Workers retry temporary failures with exponential backoff and jitter, and move a job to a dead letter queue after a fixed number of attempts. Permanent failures, such as an invalid address, are not retried.

Tracking delivery

Providers report what happened through webhooks or response codes: accepted, delivered, bounced, opened. Write every status change to a delivery log keyed by the notification ID. Support can then answer "did the user get the reset email?" in seconds, and you can measure delivery rates per channel and provider.

Status updates arrive out of order and sometimes twice. Store them as events with timestamps and derive the current state, instead of overwriting a status column.

In-app notifications

Many products also keep an in-app inbox: the bell icon with a count. Treat it as a fourth channel that writes to a database instead of calling a provider. Store one row per user and notification with a read flag, index it by user and time, and let the app fetch the latest page and an unread count. Mark items read in batches, so a user opening the inbox does not trigger fifty writes.

Back-of-envelope numbers

At one million daily active users and five notifications per user per day, the system sends 5 million notifications a day, about 58 per second on average. Peaks are what matter: a campaign or an incident alert can send a million messages in a few minutes. Size the queues for the burst, let workers drain them at the rate each provider allows, and expect providers to throttle you before your own workers run out of capacity.

The architecture diagram

Product services send events to a notification API, which reads a preferences store and routes work to a push queue, an email queue and an SMS queue. Each queue feeds its own workers, which call APNs and FCM, an email provider or an SMS provider. Providers report status to a delivery log.

14 components · 14 connections

Open in Archboard
Diagram contents
  • Product services
  • Notification API
  • Preferences
  • Push queue
  • Email queue
  • SMS queue
  • Push workers
  • Email workers
  • SMS workers
  • APNs and FCM
  • Email provider
  • SMS provider
  • Delivery log
  • event
  • opt-outs, quiet hours
  • status
  • webhooks
A notification system with one API in front of three channels. The API dedupes, checks preferences and renders templates, then each channel has its own queue, workers and provider, and delivery statuses come back into one log.

How to read it:

  • Left: product services send events with an idempotency key to the notification API. The API reads the preferences store and runs the checks listed in the Markdown block.
  • Middle: each channel has its own queue and worker pool, so a slow provider only backs up its own channel.
  • Right: workers call APNs and FCM, the email provider and the SMS provider. Status updates flow back to the delivery log as dotted edges because they arrive later and asynchronously.

What to put on the diagram

  • The checks and their order. Dedupe, preferences, caps and rendering are the system's real logic.
  • One lane per channel. If all channels share one queue on your diagram, they probably share one in production too.
  • Retry and dead letter behavior. Note the backoff and the attempt limit on each worker.
  • The status path back. Without it, nobody can debug a missing message.

For the queue and consumer side in more depth, see the event-driven architecture post. The microservices architecture diagram shows where a shared service like this one sits among the others, and the GDPR data mapping post is worth reading before you store contact details and delivery logs.

Build it in Archboard

  1. Sign in with Google or GitHub and create a diagram.
  2. Add a Container from the C4 Model pack for the product services and another for the notification API, and a Database below the API for preferences.
  3. Add three Queue nodes from the Data pack, one per channel, and a Container for each channel's workers.
  4. Add three External System nodes from the C4 Model pack for the providers and a Database for the delivery log.
  5. Draw the status edges as dotted lines and add a Markdown block from the Core pack with the pre-send checks.

The example uses 14 diagram nodes, within the Free plan.

FAQ

Why not let each service send its own notifications? Because preferences, quiet hours, deduplication and delivery tracking then have to be built in every service, and they drift apart. One notification service applies them the same way everywhere.

How do I stop duplicate notifications? Require an idempotency key on every request and store it for a while. A repeated key is acknowledged and dropped.

Should push, email and SMS share a queue? No. Separate queues and worker pools keep a slow or failing provider from delaying the other channels.

How do I handle quiet hours? Store each user's time zone and quiet window, and schedule non-urgent messages for the end of the window instead of sending or dropping them.

How do I know a notification was delivered? Record provider responses and webhooks in a delivery log keyed by notification ID. Delivery means different things per channel, so record the provider's own status names.

Start free — one seat, no credit card.

Related posts

← Back to blog

Open a canvas in a minute

Sign in with Google or GitHub. Free for a single seat. Upgrade when the team joins.