Medium · reference design
Notification system
Send push, email and SMS on behalf of every product team: urgent messages in seconds, campaigns at their own pace, user preferences respected and no visible duplicates.
1 Requirements
Functional
- Product services send a notification to a user through one API, choosing a template and a priority.
- Deliver through push, email or SMS, according to each user’s preferences and opt-outs.
- Track the status of every notification: queued, sent, delivered or failed.
Non-functional
- Urgent messages (sign-in codes, security alerts) arrive within seconds, even during a large campaign.
- At-least-once delivery with no duplicates the user can see.
- A failing provider affects only its own channel.
- Handle bursts of hundreds of thousands of notifications a minute.
Out of scope: Designing the message content and templates editor. In-app inbox UI.
2 Core entities
- Notification
- Who, what template, which data, priority and an idempotency key.
- Preferences
- Per user and channel: allowed, quiet hours, opt-outs.
- Template
- The message per channel and language, with placeholders.
- Delivery attempt
- One try on one channel: provider, result, timestamps.
3 API
POST /notificationsBody: userId, templateId, data, priority, idempotencyKey. Returns 202 with a notificationId.
GET /notifications/{id}Status per channel and the delivery attempts so far.
PUT /users/{id}/preferencesChannels allowed, quiet hours and opt-outs.
4 Data flow
- A service calls the API; the request is validated and deduplicated.
- Preferences decide which channels, if any, the user gets.
- The router renders the template and puts the message on a queue for its priority.
- Channel workers send through the provider, retrying on failure, and record every attempt.
5 High-level design
Press play to follow one request through the system. Every step explains what happens and the trade-off behind the choice.
Follow one notification from the service that asks for it to the provider that delivers it. Press play, or step through the stages to see what happens and what each choice costs.
6 Deep dives
How do we avoid duplicates with at-least-once queues?
| Option | Gives | Costs |
|---|---|---|
| Trust the queue | Nothing to build. | Redelivery after a worker crash sends the message again. |
| Check the delivery log before sending | One lookup per send catches redeliveries. | A crash between the send and the log write can still duplicate. |
| Log check plus provider idempotency key | Closes the last gap where the provider supports keys. | Not every provider supports them. |
Choice. Check the log and pass an idempotency key where the provider supports it. The remaining gap is small and only on providers without keys.
How do we not overwhelm providers or users?
| Option | Gives | Costs |
|---|---|---|
| Send as fast as workers can | Simple. | Hits provider rate limits and can spam users. |
| Token buckets per provider and per user | Stays under provider quotas and caps messages per user per day. | Bulk sends take longer. |
Choice. Token buckets per provider and per user, with urgent messages exempt from the per-user cap.
How does a campaign to millions of users start?
| Option | Gives | Costs |
|---|---|---|
| The caller sends one request per user | No new component. | Millions of API calls from one service, hard to pause or cancel. |
| A fan-out job expands an audience in batches | One request starts it; it can be paused, resumed and throttled. | Another job to run and monitor. |
Choice. A fan-out job that writes batches into the bulk queue, so a campaign can be throttled or stopped halfway.
In one sentence
One API that accepts fast and deduplicates, preferences first, a queue per priority, workers per channel with safe retries, and every attempt on record.