Build Meta's Notification Delivery System
scalability distributed-systems performance
System Design Deep Dive
Meta's Notification Delivery System
Routing billions of alerts across push, in-app, SMS, and email without overwhelming users or drowning in a viral spike
Think of a postal sorting office that handles 10 billion letters a day, each letter addressed to a single person, but every person has a different preference: some want overnight delivery, some refuse mail before 9am, some have banned certain categories of sender entirely. Now imagine that once a month a single viral event - a celebrity post, an election result, a product launch - causes the sorting office to receive ten times its usual volume in a single hour. That is the core problem of building a notification system at Meta’s scale.
Meta’s notification platform routes alerts across four channels: Apple Push Notification service (APNs) for iOS, Firebase Cloud Messaging (FCM) for Android, SMS via telecom gateways, and email. At baseline, the system delivers roughly 10 billion notifications per day - about 115,000 per second. During a viral event, that rate spikes to 10x in minutes: 1.15 million per second. Each notification must respect the recipient’s channel preferences, do-not-disturb windows, and frequency caps - and it must do so with sub-second end-to-end latency for high-priority alerts.
The tensions are real. Fan-out workers must push to millions of users quickly, but doing so naively overwhelms downstream channel providers. Preference lookups must be fast (under 5ms) or they become the bottleneck. Deduplication must prevent the same notification from firing three times due to retry storms, but the dedup window must be short enough not to suppress legitimate repeat events. None of these are independently hard - the challenge is making all of them work together at scale.
Requirements and Constraints
Functional Requirements
- Accept notification events from any internal service (activity feed, payment system, auth service)
- Route each notification to one or more delivery channels based on user preferences and notification type
- Respect per-user channel opt-outs, do-not-disturb windows, and per-channel frequency caps
- Deduplicate notifications so retried events do not produce duplicate alerts
- Track delivery status per notification (sent, delivered, read, failed)
- Support at least 4 channels: push (APNs/FCM), in-app, SMS, email
Non-Functional Requirements
- Baseline throughput: 115,000 notifications/second
- Spike throughput: 1,150,000 notifications/second (10x for up to 1 hour)
- End-to-end latency for P0 notifications: p99 under 500ms
- Preference lookup latency: p99 under 5ms
- Deduplication window: 30 seconds (same event+user key suppressed within window)
- Delivery receipt recording: within 10 seconds of send
- System availability: 99.99% (52 minutes downtime/year)
Constraints
- APNs and FCM impose their own rate limits per app certificate; SMS gateways charge per message and throttle at 100/second per Twilio number
- Push channels are best-effort: APNs/FCM may silently drop notifications for offline devices
- Email has the highest deliverability complexity: SPF/DKIM, bounce handling, spam classification
- Users can change preferences at any time; the preference store must reflect changes within 5 seconds
High-Level Architecture
The system splits into three logical layers: the notification core (ingestion, routing, fan-out), the channel adapters (per-provider delivery), and the feedback layer (delivery receipts, retry, analytics).
Notification Service is the entry point. Internal services publish notification events to a Kafka topic. The notification service consumes these events, applies an initial deduplication check against Redis, and writes the notification record to a persistent store (Cassandra) before handing off to the routing engine. Writing to Cassandra before dispatching is critical - it ensures no notification is lost even if the routing tier crashes.
The Routing Engine looks up user preferences from a Redis-backed preference store, evaluates do-not-disturb windows and frequency caps, and emits one task per channel per user to the appropriate priority queue. Priority queues are implemented in Kafka with separate topics per priority tier (P0 through P3).
Fan-out Workers consume from the priority queues and call channel adapters. For viral events generating millions of notifications for a single notification type, fan-out workers are the horizontal scaling lever - more consumers on the Kafka topic means proportionally more throughput.
Channel Adapters are thin wrappers over external provider APIs. They translate internal notification payloads into provider-specific formats and handle provider-specific error codes. Each adapter reports delivery outcomes back to the feedback layer via an internal event.
The decision to use Kafka as both the ingestion buffer and the priority queue backbone is deliberate. Kafka gives you consumer-lag metrics for free, which is exactly what you need to trigger autoscaling. When consumer lag on the P1 topic crosses 50,000 messages, the autoscaler adds worker pods. Lag falling below 5,000 triggers scale-in. Without Kafka's visible lag, you would need a separate queue-depth monitoring system.
Notification Routing Engine
The routing engine is the brain of the system. It answers one question for every incoming notification: which channels should deliver this alert to this user, right now?
Every notification event carries a type (like, comment, security alert, marketing), a priority (P0 through P3), and a recipient user ID. The routing engine evaluates four conditions in sequence before dispatching to any channel.
First, it performs a deduplication check. If the same event ID has been seen within the last 30 seconds (checked via Redis SETNX), the notification is dropped silently. This handles the common case of retry storms from upstream services.
Second, it loads the user’s preference record from Redis. The preference record is a compact structure encoding channel opt-ins as a bitmask, per-channel frequency cap counters, and a do-not-disturb schedule stored as a pair of Unix epoch offsets (DND start, DND end, with a repeating weekday bitmask). A preference cache miss falls back to the preference database (MySQL) and re-populates the cache with a 60-second TTL.
Third, it evaluates the DND window. If the current time falls within the user’s DND schedule, P2 and P3 notifications are held in a delayed queue and re-enqueued at the DND end time. P0 (security, OTP) and P1 (social) notifications bypass the DND window.
Premium Content
Unlock the full article along with everything else in the archive — all in one place.