Backend

Backend

Designing a Notification System

A classic system-design walkthrough: how to fan a single business event out to push, email, SMS, and in-app channels without spamming users or duplicating sends.

8 min read
System DesignNotifications

"Design a notification system" shows up in system-design interviews because it looks simple on the surface — send a message when something happens — and quietly isn't. A real system has to talk to four or five completely different delivery mechanisms, react to events happening all over the rest of the platform, respect what each user actually wants to hear about, and survive provider outages without either losing notifications or sending the same one five times. Almost every interesting design decision falls out of those constraints.

One service, many channels

Push notifications go through APNs or FCM. Email goes through something like SES or SendGrid. SMS goes through Twilio or a regional aggregator. In-app notifications just get written to a database and read back by the client. Each of these has its own API shape, its own auth, its own rate limits, and its own way of failing — APNs will reject a stale device token, an email provider will soft bounce, an SMS carrier will silently drop a message with no delivery receipt at all.

If every part of the application that wants to notify a user calls these provider APIs directly, that logic — and every quirk of every provider — ends up duplicated across the codebase. The fix is to pull all of it behind a single notification service with one internal interface: "send this user this notification, through these channels." The rest of the platform doesn't know or care that push means APNs and email means SES; it just calls the notification service and lets that service own the provider integrations, the credentials, the retry logic, and the failure handling for each one. Swapping SendGrid for SES later becomes a change in one place instead of a grep across the whole codebase.

Notifications are driven by events, not by callers

The cleanest version of this design doesn't have the order service, the comments service, and the billing service all calling the notification service's API directly either. Instead, each service publishes an event when something notification-worthy happens — an order shipped, a comment got a reply, a payment failed — and the notification service is just another consumer of those events. It subscribes to the event streams it cares about, decides whether a given event should produce a notification at all, and if so, which channels it should go out on.

This is the same event-driven architecture pattern you'd use for any cross-cutting concern: the service that ships an order shouldn't need to know that shipping triggers a push notification, an email receipt, and an in-app banner. It just publishes "order shipped" and moves on. That decoupling is what lets the notification team add a new channel, or change how an existing event maps to channels, without ever touching the order service's code.

json
{
  "eventId": "order.shipped:ord_9K2L:v1",
  "eventType": "order.shipped",
  "userId": "usr_4471",
  "occurredAt": "2026-05-19T14:02:31Z",
  "payload": {
    "orderId": "ord_9K2L",
    "carrier": "UPS",
    "trackingNumber": "1Z999AA10123456784"
  }
}

Preferences and rate limiting

Not every user wants every event on every channel. A per-user preference table — keyed by event type and channel — lets someone turn off marketing email while keeping order updates on, or disable push but keep SMS for security alerts. The notification service checks this table before it sends anything, and a sane set of defaults matters here: opt-out for marketing, opt-in-by-default for transactional and security notifications.

Preferences alone don't stop a buggy upstream service from firing the same event type fifty times in a minute. On top of preferences, the system needs rate limiting per user, per channel — a cap like "no more than one push notification per minute" or "at most 10 SMS per day", enforced with a sliding window counter keyed on user and channel.

Fatigue is a one-way door

The real cost of over-notifying isn't one annoyed user — it's that once someone disables push notifications for your app, they almost never turn them back on. Rate limiting isn't just about protecting downstream providers; it's about protecting the channel itself from being permanently switched off.

Batching and digesting

Rate limits cap volume, but they don't solve the underlying problem when a burst of legitimate events happens close together — twenty people commenting on the same post within a few minutes shouldn't mean twenty separate push notifications. The notification service can hold a short buffering window per user per event type, and if more events arrive for the same context before the window closes, collapse them into a single digest: "5 new comments on your post" instead of five interruptions.

This matters on both ends. For the user, one meaningful summary beats a stream of near-identical pings. For the system, it means one provider call instead of many, which matters when a provider is rate-limited or when you're paying per SMS segment. The tradeoff is latency — a digest window trades "instant" for "a few seconds or minutes later, but consolidated" — so time-sensitive events like a failed login should usually bypass batching entirely.

Retries and falling back across channels

Every provider call is a network call to a third party, and third parties fail transiently — a timeout, a 503, a temporary rate-limit response. The notification service should retry these with exponential backoff and a capped number of attempts, the same way you'd retry any flaky downstream dependency.

Retries handle transient failures on the same channel; channel fallback handles the case where a channel fails outright — the push token is invalid, the device is offline for days, or APNs itself is degraded. For a notification that's actually important, the service can fall back to a secondary channel: if push delivery fails or isn't confirmed within some window, send the same notification by email instead. This needs to be configured per event type, since a marketing push failing shouldn't suddenly generate an email, but a security alert probably should try every channel it has.

Making retries safe: idempotency

Retrying by definition means attempting the same operation more than once, and a naive retry — on the notification service's own internal delivery pipeline, not just the provider call — risks double-sending: the request succeeds at the provider but the acknowledgment is lost, so the caller retries and now the user gets the same push twice. The standard fix is the same one used anywhere retries and side effects meet — an idempotency key derived from the triggering event, so that reprocessing the same "order shipped" event twice resolves to the same notification record instead of a second send. The notification service records that a given event ID has already been handled before it dispatches to a provider, so replays of that event are recognized and skipped rather than delivered again.

Key takeaways

  • Put one channel-agnostic notification service in front of push, email, SMS, and in-app delivery so provider quirks and credentials live in a single place.
  • Drive notifications from published business events rather than direct calls, so upstream services stay decoupled from what happens next.
  • Per-user, per-channel preferences plus rate limiting protect users from fatigue that leads them to disable notifications for good.
  • Batching rapid-fire events into a single digest improves the user experience and cuts load on downstream providers.
  • Retry transient provider failures with backoff, and fall back to a secondary channel when a channel fails outright for important notifications.
  • Key deliveries by the triggering event's ID so retries and replays land as no-ops instead of duplicate sends.
2026 — Built by Arpit Mishra