The prompt
Every product grows notifications team by team: one sends emails directly, another calls a push provider, a third has its own SMS account. Then users get the same alert three times, at 3 a.m., with no way to turn it off. "Design a notification system" asks for the shared platform that fixes that.
Snip's teams need to notify users by push, email, SMS and in-app messages: login codes, "your link is trending", weekly reports, product news. Design a notification service they all call. Assume 50 million notifications a day, bursts of a few million when a campaign goes out, and login codes that must arrive within seconds.
1. Requirements
Functional
- One API: send a notification of a given type to a user, a list of users or a segment, now or at a set time.
- Templates per type and channel, filled with data from the caller.
- Users control which types they get on which channels; quiet hours in their own time zone.
- Delivery status per notification and channel.
Non-functional
- Login codes and security alerts delivered within seconds, even during a campaign.
- No user gets the same notification twice on one channel.
- Bulk sends spread out within the providers' limits.
2. Estimates
- Rate: 50 million a day is about 600 a second on average; a campaign to 5 million users, spread over 10 minutes, is about 8,000 a second on top.
- Delivery log: 50 million rows a day at about 300 bytes is 15 GB a day; kept 90 days, about 1.4 TB.
- Device tokens: 40 million users × 1.5 devices = 60 million tokens: a table, not a big-data problem.
3. Entities and API
POST /notifications
{ idempotency_key, type: "link.trending", to: { user_id } | { segment },
data: { code, clicks }, channels?: [...], send_at? } → 202 { notification_id }
Preferences { user_id, type, channel → on/off, quiet_hours: 22–07, time_zone }
Device { user_id, platform, token, last_seen }
Delivery { notification_id, channel, status, provider_id, sent_at } primary key (notification_id, channel)Callers say what happened and to whom; the platform decides whether, how and when to tell them.
4. High-level design
5 steps. Use Next and Back to follow the request.
Text version
- A service calls the API with an idempotency key; a retried call returns the first notification's ID instead of creating another (03.10).
- A notification to a segment goes to the expander, which pages through recipients in batches of 1,000 (12.08).
- The decider applies each user's preferences, quiet hours and frequency caps, renders the templates, and picks channels and devices.
- Urgent types (login codes, security alerts) go to the urgent lane; everything else to the bulk lane. Each lane has its own queues and workers.
- Channel senders claim each (notification, channel) before sending, pace themselves to each provider's limits, and record the result.
- Product services → Notification API
- Notification API → Expander: segments
- Notification API → Decider
- Expander → Decider
- Decider → Preferences + devices
- Decider → Urgent lane
- Decider → Bulk lane
- Urgent lane → Channel senders
- Bulk lane → Channel senders
- Channel senders → Providers
5. Deep dives
Deep dive
A campaign must never delay a login code
A 5-million-user campaign is queued at 10:00; a login code queued at 10:01 waits behind it. The user gives up signing in.
Better, but workers busy with slow bulk sends and a provider throttling bulk traffic still slow everything down.
Urgent and bulk notifications use different queues and worker pools, and each channel has its own, so a slow email provider never holds up push. The same isolation as multi-tenancy (14.09 Multi-tenancy and noisy neighbours): one noisy workload can't starve another.
Deep dive
Sending once, when events arrive twice
A worker crashes between the two; the queue redelivers the event (07.03 Delivery guarantees and idempotent consumers), and the user gets it again.
Stops duplicate requests from callers, but not duplicates created inside the pipeline by redelivery.
Insert a delivery row keyed by notification and channel first; only the worker whose insert succeeds sends. A
worker that crashes after claiming leaves a row stuck in sending; a sweeper retries urgent ones (better twice
than never for a login code) and drops stale bulk ones.
export async function deliverOnce(db: pg.Pool, notificationId: string, channel: string, send: () => Promise<void>) {
const claimed = await db.query(
`INSERT INTO deliveries (notification_id, channel, status) VALUES ($1, $2, 'sending')
ON CONFLICT DO NOTHING RETURNING notification_id`,
[notificationId, channel],
);
if (claimed.rowCount === 0) return "duplicate"; // a redelivered event: someone already has it
await send();
await db.query(`UPDATE deliveries SET status = 'sent', sent_at = now() WHERE notification_id = $1 AND channel = $2`, [
notificationId,
channel,
]);
return "sent";
}
/** Quiet hours in the user's own time zone, which may wrap past midnight (22 → 7). */
export function inQuietHours(now: Date, timeZone: string, startHour: number, endHour: number) {
const hour = Number(new Intl.DateTimeFormat("en-GB", { hour: "2-digit", hourCycle: "h23", timeZone }).format(now));
return startHour <= endHour ? hour >= startHour && hour < endHour : hour >= startHour || hour < endHour;
}Tested on Postgres 16: the same event delivered to five workers at once produced one send and four duplicates; the same notification on the email channel was sent separately, as it should be. At 17:30 UTC, quiet hours of 22:00 to 07:00 were in force for a user in Kolkata (23:00 there) and not for one in London; in Kolkata, 06:59 was still quiet and 07:00 wasn't.
Deep dive
Respecting the user
Some forget, each stores settings differently, and "turn off marketing" means six settings pages.
One place for opt-outs per type and channel. But it doesn't stop ten legitimate notifications in an hour.
The decider holds bulk notifications during quiet hours (urgent ones go through), caps how many of each kind a user gets per day, and rolls the excess into a digest. Legal opt-outs, such as for marketing email, are enforced here too, so no caller can bypass them.
Providers are best-effort. Apple's and Google's push services may reorder notifications, keep only a limited number for an offline device, and drop some; they report tokens that are no longer valid, and senders must delete those tokens or keep paying to send to dead devices. For SMS and email, a second provider behind a circuit breaker (08.09 Circuit breakers and bulkheads) keeps login codes working when one provider has an outage. Anything that must be seen, like a security alert, also goes to an in-app inbox stored by Snip itself.
6. Wrap-up
What breaks first: campaign spikes that hit provider limits, so bulk senders pace themselves with token buckets per provider (14.03 Rate limiting algorithms); then the delivery log, which grows fastest and is partitioned by day and expired after 90 days (14.11 Data retention, deletion and privacy).
What's expected at each level
Junior
gets a working design
- A queue between the API and workers that call the providers
- Stores device tokens and user preferences
Mid-level
explains the trade-offs
- Separates urgent from bulk traffic
- Applies preferences and quiet hours in the user's time zone
- Makes sends idempotent per notification and channel
Senior
drives it and goes deep
- Isolates lanes per channel and adds provider failover
- Handles crashed sends differently for urgent and bulk
- Treats push as best-effort and keeps an in-app inbox for what must be seen
Seen in production
Apple
DocsApple Push Notification service
APNs is described as a best-effort service that may reorder notifications sent to the same device token. If it
can't deliver a notification immediately, it may store it for up to 30 days, depending on the expiration you set,
but it stores only one notification per app (bundle ID), usually the latest. Payloads are limited to 4 KB, and an
apns-collapse-id of up to 64 bytes merges repeated notifications into one.
What we foundPush is best-effort: notifications may be reordered, and an offline device gets at most one stored notification per app.
Sending notification requests to APNs (Apple Developer documentation)developer.apple.com ↗Firebase Cloud Messaging
A collapsible message may be replaced by a newer message if it hasn't been delivered yet, while each non-collapsible message is delivered individually. FCM can store four different collapsible messages per device, each with a different collapse key. Delivery order isn't guaranteed, and the maximum payload is 4,096 bytes.
What we foundMessages that supersede each other share a collapse key, so an offline device receives only the newest.
Collapsible and non-collapsible messages (Firebase documentation)firebase.google.com ↗A worker claims (notification 81, push), sends the push, then crashes before marking it sent. The event is redelivered. What happens with Snip's design?
Recap
Interview questions
Design a notification system that supports push, email and SMS.