Why you need a notification system, and when to use one
Apps send emails, SMS messages and mobile push notifications for receipts, alerts, reminders and marketing. Each goes through a third-party provider that can be slow, down or rate-limited. If the code that handles a user request calls the provider directly, a provider outage fails the request or silently loses the message.
A notification system accepts events, decides who should get what on which channel, and delivers reliably in the background. It gives you two things interviewers always ask about:
- Reliability: every important notification is delivered, even if a provider is down, and duplicates are kept as rare as possible. Exactly once is not fully achievable, as the section on delivery guarantees explains.
- Scalability: a burst (a flash sale, a breaking alert) is spread over time instead of overwhelming providers or users.
Use one when:
- Several services send notifications and they should share channels, templates and rules.
- Messages must survive provider outages and be retried.
- Users have preferences, quiet hours and unsubscribe choices to respect.
- You must limit how often someone is contacted.
Skip it, or use something else, when:
- One service sends a few emails: call the provider through a small queue or a library.
- The message must be seen instantly in the page: use the live connection or polling instead of a channel like email.
Try it
Six emails must be sent. Change the design, then break the email provider.
Notification System
Step 1 of 11 · Press Space to play, arrow keys to stepInitial state. The app must send 6 emails, one per tick. The provider is a third party that can fail.
- tick:
- 0
- delivered:
- 0
- lost:
- 0
- duplicates:
- 0
- waiting in queue:
- 0
1 tick = one step of the simulation.
Things to try:
- Run Direct with Email provider is down. The emails sent during the outage are lost.
- Run Queue + workers with the same failure. Retries with back-off deliver everything after the provider recovers.
- Try the reply is lost, so we retry with each design. Direct sends duplicates, but the queue with an idempotency key does not.
What to watch (the provider is down for ticks 2 to 5, which is 4 ticks):
- Direct, provider down: emails 2, 3, 4 and 5 are lost. Only emails 1 and 6 arrive, so the end counters read 2 delivered and 4 lost.
- Queue, provider down: the waits before each retry grow (1, 2, then 4 ticks, and the simulation stops growing them at 4). Emails 3, 5 and 6 arrive at tick 6, email 4 at tick 7 and email 2 at tick 9. The end counters read 6 delivered, 0 lost and 0 waiting.
- Direct, reply lost: email 2 arrives twice, so the counters show 1 duplicate.
- Queue, reply lost: the retry of email 2 at tick 3 is ignored because it has the same key, so duplicates stay at 0.
- Assumption to remember: the simulation lets the provider ignore a repeated key. Real providers do not all do this, as the next sections explain.
Requirements and rough numbers
Before choosing a design, state what it must do and how big it is.
Functional requirements: accept events from many services, choose the channel (email, SMS, push), fill in a template, respect each user's preferences and quiet hours, deliver, retry, and keep a delivery record.
Non-functional requirements: do not lose important messages, keep duplicates rare, send urgent messages such as one-time codes within seconds, absorb bursts without hurting providers, and stay up when a provider is down.
Illustrative sizing (assumed numbers, not measurements): 10 million users who each receive 5 notifications a day.
| Quantity | Working | Result |
|---|---|---|
| Notifications per day | 10,000,000 x 5 | 50 million |
| Average rate | 50,000,000 / 86,400 seconds | about 580 per second |
| Peak rate if bursts are 10 times the average | 580 x 10 | about 5,800 per second |
| Delivery log per day if each row is about 500 bytes | 50,000,000 x 500 bytes | about 25 GB |
| Backlog after a 1 hour outage at the average rate | 580 x 3,600 seconds | about 2.1 million messages |
The average is small. The peak, the provider limits and the backlog after an outage are what shape the design, which is why the queue exists.
Notification delivery strategies
| Strategy | How it works | Good when | Weakness |
|---|---|---|---|
| Direct call | The request calls the provider and waits | Prototypes and rare messages | Slow requests, lost messages on failure |
| Queue and workers | Events go on a queue. Workers send in the background | Most production systems | Needs a queue and retry rules |
| Priority queues | Separate queues for urgent and bulk messages | Alerts and one-time codes mixed with marketing | More queues to run and monitor |
| Batching and digests | Combine many events into one message | Reduce noise, cut cost | Delay |
| Outbox plus queue | Write the event with the business data, publish it afterwards | Notifications must match committed data | A relay to run |
At least once vs exactly once
Queues deliver at least once, and a timeout does not tell you if the provider sent the message. So a retry can send twice. Providers cannot do true exactly-once, so you use an idempotency key (such as the notification ID). Your own system checks it so a repeat is ignored, and the provider checks it too where it offers such a feature. The trade-off is storing keys for a while.
Be honest about the limits. Not every provider supports idempotency keys, so check the documentation of the one you use. Even your own check has a gap: if a worker sends a message and crashes before it records that it sent it, the retry sends it again. The goal is very rare duplicates, not zero. The simulation above assumes the provider ignores a repeated key, which is the best case.
How retries and back-off work
- A worker takes a message and calls the provider.
- On a temporary failure (a timeout, a server error, a rate limit) it puts the message back with a delay that grows each time (exponential back-off), often with some randomness (jitter).
- After a maximum number of attempts, it moves the message to a dead letter queue for people or a separate process to look at.
- On a permanent failure (an invalid address, an unsubscribed user) it stops at once and records why.
- It records every outcome, so support can answer "did my email go out?"
Three details matter in practice. Cap the wait (for example at a few minutes) so it does not grow without limit. If a provider says how long to wait, such as a rate limit reply with a retry time, use that instead of your own guess. Give messages an expiry, because a one-time code that arrives an hour late is useless and should be dropped, not sent. Note that with a limit of five attempts there are only four waits between them, as the example below shows.
Notification system vs the alternatives
| Concern | Queue-based service | Direct provider calls | Database polling job |
|---|---|---|---|
| Survives a provider outage | Yes | No | Yes |
| Keeps requests fast | Yes | No | Yes |
| Handles bursts | Yes | No | Slowly |
| Respects preferences in one place | Yes | Each service re-implements | Possible |
| Latency to send | Low when healthy | Lowest | Higher |
| Extra moving parts | Queue, workers | None | A job and a table |
| Best for | Production systems | Prototypes | Small systems |
Use the queue-based service when notifications matter. Direct calls are fine for a prototype.
Avoiding a new single point of failure
- Queue: use a durable, replicated queue so accepted events survive a crash.
- Workers: run several. If one dies, its unacknowledged messages are redelivered once a timeout (often called a visibility timeout or lease) expires. Some queues do this for you. With Redis Streams another worker must claim the pending messages, and a database queue needs a lease column.
- Providers: configure a second provider per channel and fail over when the first is unhealthy.
- Preference and template services: cache them, and fall back to safe defaults.
Three ideas from the strategies table and the interview deserve a line each:
- Outbox: if a service saves an order and then publishes an event, a crash between the two loses the event. The outbox pattern saves the event in the same database transaction as the order, and a relay publishes it afterwards. The relay may publish twice, so the idempotency key is still needed.
- Circuit breaker: when a provider keeps failing, workers stop calling it for a short time instead of hammering it, then try one request to see if it has recovered.
- Failover has a cost: if the first provider timed out, you cannot know whether it sent the message. Sending through the second provider may deliver twice, and an idempotency key at the first provider does not help. For one-time codes this is acceptable. For a receipt you may prefer to wait.
You accept a delay of seconds during failures, and the complexity of retries and duplicate handling.
Bottlenecks and scaling
- Draining a backlog: after an outage, the queue holds everything that arrived meanwhile. If the workers can send 3,000 messages per second in total (an assumed figure) and live traffic is 580 per second, only 2,420 per second is spare. The 2.1 million message backlog from the sizing table then needs about 2,100,000 / 2,420, roughly 870 seconds or 14 minutes. During that time, fresh messages wait behind old ones, so urgent traffic needs its own queue.
- Provider limits: providers limit how fast you may send. Workers need a rate limiter per provider, otherwise a backlog turns into a wave of rejected requests. For example, a bulk campaign of 5 million emails at 1,000 per second takes 5,000 seconds, about 83 minutes.
- Scaling the queue: split queues by channel and priority, and split a busy queue into partitions so many workers read in parallel. Add workers when the queue depth keeps growing.
- Per-user limits: a service may emit many events for one user. Apply a per-user rate limit or a digest before sending, not after.
- Push notifications: a push needs a device token. Tokens expire or are revoked, so when the push service reports an invalid token, delete it. That is a permanent failure and should not be retried.
- The delivery log: it grows with every message. Keep recent rows hot and archive or drop old ones.
Common mistakes
- Sending inside the user's request. A slow provider slows the user.
- Retrying without an idempotency key. Users get duplicates.
- Infinite retries. A bad message loops for ever. Cap attempts and use a dead letter queue.
- One queue for everything. A marketing burst delays a one-time password. Separate by priority.
- Ignoring preferences and quiet hours. It annoys users and can break the anti-spam and data protection rules of the regions you send to. Check what applies to you.
- No delivery record. Nobody can tell what was sent.
How to talk about a notification system in an interview
- Ask which channels (push, SMS, email), what volume, and which messages are critical.
- Draw services, a queue, per-channel workers, providers, a preferences store and a delivery log.
- Cover retries with back-off, a dead letter queue, idempotency, priority and rate limits per user.
- Name the trade-off: a queue gives reliability and smooth load at the cost of a small delay and more parts.
- Start with requirements and one back-of-the-envelope estimate (events per day, average and peak per second) and say what the peak and the outage backlog mean for the number of workers.
- Say where duplicates are stopped (your own store, the provider where it supports keys) and admit that a rare duplicate can still occur.